Skip to main content

tenzro_token/
seed_agent.rs

1//! SeedAgent treasury allocation primitive (Agent-Swarm Spec 10 — wave 1).
2//!
3//! The full spec ([seed-agent.md](../../../docs/architecture/agent-swarm/seed-agent.md))
4//! describes a treasury earmark that funds protocol-owned autonomous
5//! agents during the first 12 mainnet months. SeedAgents register TDIP
6//! identities, post bonds, and exercise the full stack (inference, 7683
7//! intents, channels, bridges) so that providers, template marketplaces,
8//! and dispute pipelines have real volume before organic demand arrives.
9//!
10//! This module lands the on-chain accounting primitives:
11//!
12//! - [`TreasuryEarmark`] — the singleton allocation record with decay
13//!   schedule and aggregate draw counters.
14//! - [`Charter`] — a governance-signed mandate enumerating which
15//!   operations a class of SeedAgents may perform, with spend caps and a
16//!   counterparty filter.
17//! - [`SeedAgentRecord`] — per-agent provisioning state: which charter
18//!   the agent operates under, how much of its allocation is used, and
19//!   the agent DID + bond reference.
20//! - [`SeedAgentEarmarkManager`] — owns the singleton + charter set +
21//!   per-agent registry with optional RocksDB write-through.
22//!
23//! All of Spec 10 is now shipped: the primitives + hydration + read
24//! access, the governance executor wiring (charter add/modify/terminate /
25//! earmark / status), the per-month decay enforcement at refill, the
26//! off-chain [`SeedAgentDaemon`](crate::seed_agent_daemon::SeedAgentDaemon)
27//! that drives monthly refills and pauses agents under sunsetted charters,
28//! the [`SEED_AGENTS_TOPIC`](crate::seed_agent_gossip::SEED_AGENTS_TOPIC)
29//! gossipsub fan-out (`tenzro/seed-agents`), and the sunset wind-down sweep
30//! ([`SeedAgentEarmarkManager::sunset_wind_down_sweep`]) that
31//! Paused→Quarantined→Terminated agents under sunsetted charters and
32//! disposes the residual surplus.
33//!
34//! Storage layout (`CF_TOKENS`):
35//! - `seed_earmark:singleton` → JSON-encoded [`TreasuryEarmark`].
36//! - `seed_charter:<charter_id_hex>` → JSON-encoded [`Charter`].
37//! - `seed_agent:<agent_did>` → JSON-encoded [`SeedAgentRecord`].
38//!
39//! All TNZO amounts are 18-decimal base units, consistent with the rest
40//! of `tenzro-token`.
41
42use crate::error::{Result, TokenError};
43use serde::{Deserialize, Serialize};
44use std::sync::Arc;
45use tenzro_storage::{KvStore, WriteOp, CF_TOKENS};
46use tenzro_types::primitives::{Hash, Timestamp};
47use tracing::{debug, info};
48
49/// Storage key for the singleton [`TreasuryEarmark`] under `CF_TOKENS`.
50pub const SEED_EARMARK_KEY: &[u8] = b"seed_earmark:singleton";
51/// Prefix for per-charter records: `seed_charter:<charter_id_hex>`.
52pub const SEED_CHARTER_PREFIX: &[u8] = b"seed_charter:";
53/// Prefix for per-agent records: `seed_agent:<agent_did>`.
54pub const SEED_AGENT_PREFIX: &[u8] = b"seed_agent:";
55
56/// Default bootstrap window: 12 months in milliseconds.
57pub const DEFAULT_BOOTSTRAP_MONTHS: u8 = 12;
58
59/// Default disposition for surplus at sunset, encoded as the
60/// percentage to *burn*; the remainder returns to general treasury.
61/// `5000 bps = 50%`. Matches seed-agent.md §"Sunset and wind-down":
62/// *"Default action absent a governance vote: burn 50% / return 50%."*
63pub const DEFAULT_SURPLUS_BURN_BPS: u16 = 5000;
64
65/// Default grace window between Paused → Quarantined and Quarantined →
66/// Terminated sweeps, in milliseconds. Seven days gives any in-flight
67/// counterparty operation (channel close, 7683 settle, dispute window)
68/// time to resolve before the agent is wallet-drained.
69pub const DEFAULT_QUARANTINE_GRACE_MS: i64 = 7 * 86_400_000;
70
71/// Length of a "month" in milliseconds for decay accounting purposes.
72/// Uses a fixed 30-day window so month-index arithmetic is deterministic
73/// independent of calendar drift. 30 * 86_400_000.
74pub const MONTH_MILLIS: i64 = 30 * 86_400_000;
75
76/// Operations that a [`Charter`] may permit.
77///
78/// Enumeration matches seed-agent.md §"Charters". Any operation not in
79/// this set is rejected by SeedAgent admission, regardless of charter
80/// signature — this is a closed set of bootstrap activities.
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
82pub enum OperationKind {
83    /// Pay real providers for real inferences (charter C1).
84    InferenceConsumer,
85    /// Post tasks, accept solutions (extension of C1).
86    TaskMarketplaceConsumer,
87    /// Spawn child agents from templates and exercise spawn flow (C4).
88    TemplateInstantiator,
89    /// Exercise LZ/Wormhole/CCT/deBridge with small amounts (C2).
90    BridgeUser,
91    /// Open and resolve micropayment channels (C3).
92    SettlementProbe,
93    /// Open 7683 intents to be filled by external solvers (C5).
94    Settler7683Probe,
95    /// Bounded adversarial action for dispute pipeline exercise (C6).
96    DisputeFiler,
97}
98
99impl OperationKind {
100    pub fn as_str(&self) -> &'static str {
101        match self {
102            OperationKind::InferenceConsumer => "inference_consumer",
103            OperationKind::TaskMarketplaceConsumer => "task_marketplace_consumer",
104            OperationKind::TemplateInstantiator => "template_instantiator",
105            OperationKind::BridgeUser => "bridge_user",
106            OperationKind::SettlementProbe => "settlement_probe",
107            OperationKind::Settler7683Probe => "settler_7683_probe",
108            OperationKind::DisputeFiler => "dispute_filer",
109        }
110    }
111}
112
113/// Per-month maximum draw cap, in 18-decimal TNZO base units.
114///
115/// `month` is 0-indexed from `bootstrap_start`; month 12 (and beyond)
116/// must have `max_draw_wei == 0` for the schedule to be valid.
117#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
118pub struct DecayPoint {
119    pub month: u8,
120    pub max_draw_wei: u128,
121}
122
123/// Spend caps applied to agents operating under a charter.
124///
125/// All amounts are 18-decimal TNZO base units. Caps are enforced by the
126/// off-chain daemon at refill time and (when the governance executor
127/// wires up the on-chain enforcement) checked at admission against
128/// per-agent draw counters.
129#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
130pub struct SpendCaps {
131    /// Daily cap per agent (18-decimal base units).
132    pub daily_cap_wei: u128,
133    /// Cumulative monthly cap per agent.
134    pub monthly_cap_wei: u128,
135    /// Maximum amount that can leave on a single transaction.
136    pub per_tx_cap_wei: u128,
137}
138
139impl SpendCaps {
140    /// Reasonable C1-class genesis defaults: 10 TNZO/day per agent,
141    /// 200 TNZO/month, 1 TNZO/tx. Governance tunes these per charter.
142    pub fn default_inference_caps() -> Self {
143        Self {
144            daily_cap_wei: 10_u128 * 1_000_000_000_000_000_000,
145            monthly_cap_wei: 200_u128 * 1_000_000_000_000_000_000,
146            per_tx_cap_wei: 1_000_000_000_000_000_000,
147        }
148    }
149
150    pub fn validate(&self) -> Result<()> {
151        if self.per_tx_cap_wei > self.daily_cap_wei {
152            return Err(TokenError::InvalidParameter(
153                "per_tx_cap > daily_cap".into(),
154            ));
155        }
156        if self.daily_cap_wei > self.monthly_cap_wei {
157            return Err(TokenError::InvalidParameter(
158                "daily_cap > monthly_cap".into(),
159            ));
160        }
161        Ok(())
162    }
163}
164
165/// Optional throughput target for charters that want to drive a steady
166/// load (e.g. C1: ~10 inferences/min). Advisory only — caps still bind.
167#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
168pub struct TargetThroughput {
169    /// Operations per second, scaled by 1000 (i.e. 167 = 0.167 ops/s
170    /// = 10 ops/min). Avoids float in serialized form.
171    pub ops_per_sec_milli: u32,
172}
173
174/// Counterparty admission filter applied to all SeedAgent transactions.
175///
176/// SeedAgents are forbidden from transacting with each other (would be
177/// wash) and may optionally be restricted to a denylist of known-bad
178/// actors. The admission check enforces:
179///   1. counterparty `is_seed_agent` flag is `false`;
180///   2. counterparty DID is not in `denied_dids`.
181#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
182pub struct CounterpartyFilter {
183    /// If true, reject any counterparty whose TDIP identity has
184    /// `is_seed_agent == true`. Always set in initial charters.
185    pub deny_other_seed_agents: bool,
186    /// Explicit DID denylist. Empty by default.
187    pub denied_dids: Vec<String>,
188}
189
190impl Default for CounterpartyFilter {
191    fn default() -> Self {
192        Self {
193            deny_other_seed_agents: true,
194            denied_dids: Vec::new(),
195        }
196    }
197}
198
199/// A governance-signed mandate enumerating what a class of SeedAgents
200/// may do. Charters are immutable from agent perspective (only governance
201/// can mutate or sunset them).
202#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct Charter {
204    pub charter_id: Hash,
205    pub name: String,
206    pub purpose: String,
207    pub operations: Vec<OperationKind>,
208    pub spend_caps: SpendCaps,
209    pub target_throughput: Option<TargetThroughput>,
210    pub counterparty_filter: CounterpartyFilter,
211    pub sunset: Timestamp,
212    /// Set to false by governance to wind down a charter without waiting
213    /// for sunset; `enabled == false` blocks new agent provisioning under
214    /// this charter and signals existing agents to wind down.
215    pub enabled: bool,
216}
217
218impl Charter {
219    pub fn validate(&self) -> Result<()> {
220        if self.name.is_empty() {
221            return Err(TokenError::InvalidParameter("charter name empty".into()));
222        }
223        if self.operations.is_empty() {
224            return Err(TokenError::InvalidParameter(
225                "charter has no operations".into(),
226            ));
227        }
228        self.spend_caps.validate()?;
229        Ok(())
230    }
231}
232
233/// Per-month draw schedule. Indexed by month-from-bootstrap-start.
234///
235/// `seed-agent.md` reasonable shape: 100% allocation drawable in months
236/// 1-3, 75% in months 4-6, 50% in months 7-9, 25% in months 10-12, 0%
237/// thereafter. Total draws ≤ ~80% of allocation; the remainder either
238/// extends past month 12 (governance vote) or returns to treasury.
239#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
240pub struct DecaySchedule {
241    pub points: Vec<DecayPoint>,
242}
243
244impl DecaySchedule {
245    /// Default 12-month schedule scaled relative to a base monthly draw.
246    /// `base_monthly_draw_wei` is the cap for months 1-3 (100% rate).
247    pub fn default_with_base(base_monthly_draw_wei: u128) -> Self {
248        let p = base_monthly_draw_wei;
249        let pct = |bps: u32| -> u128 {
250            // (p * bps) / 10_000 with u128 safety
251            let prod = p.saturating_mul(bps as u128);
252            prod / 10_000
253        };
254        let points = vec![
255            DecayPoint { month: 0, max_draw_wei: pct(10_000) },
256            DecayPoint { month: 1, max_draw_wei: pct(10_000) },
257            DecayPoint { month: 2, max_draw_wei: pct(10_000) },
258            DecayPoint { month: 3, max_draw_wei: pct(7_500) },
259            DecayPoint { month: 4, max_draw_wei: pct(7_500) },
260            DecayPoint { month: 5, max_draw_wei: pct(7_500) },
261            DecayPoint { month: 6, max_draw_wei: pct(5_000) },
262            DecayPoint { month: 7, max_draw_wei: pct(5_000) },
263            DecayPoint { month: 8, max_draw_wei: pct(5_000) },
264            DecayPoint { month: 9, max_draw_wei: pct(2_500) },
265            DecayPoint { month: 10, max_draw_wei: pct(2_500) },
266            DecayPoint { month: 11, max_draw_wei: pct(2_500) },
267            DecayPoint { month: 12, max_draw_wei: 0 },
268        ];
269        Self { points }
270    }
271
272    /// Maximum draw permitted in `month` from bootstrap_start.
273    /// Months past the schedule end return 0 (hard sunset).
274    pub fn cap_for_month(&self, month: u8) -> u128 {
275        self.points
276            .iter()
277            .find(|p| p.month == month)
278            .map(|p| p.max_draw_wei)
279            .unwrap_or(0)
280    }
281
282    pub fn validate(&self) -> Result<()> {
283        if self.points.is_empty() {
284            return Err(TokenError::InvalidParameter(
285                "decay schedule empty".into(),
286            ));
287        }
288        // Final month must be 0 (hard sunset).
289        let last = self.points.last().unwrap();
290        if last.max_draw_wei != 0 {
291            return Err(TokenError::InvalidParameter(format!(
292                "decay schedule does not sunset (final month {} max_draw_wei = {})",
293                last.month, last.max_draw_wei
294            )));
295        }
296        // Months must be strictly ascending, no duplicates.
297        for w in self.points.windows(2) {
298            if w[1].month <= w[0].month {
299                return Err(TokenError::InvalidParameter(
300                    "decay schedule months not strictly ascending".into(),
301                ));
302            }
303        }
304        Ok(())
305    }
306}
307
308/// The singleton TreasuryEarmark for SeedAgents.
309#[derive(Debug, Clone, Serialize, Deserialize)]
310pub struct TreasuryEarmark {
311    pub name: String,
312    /// Genesis-allocated TNZO, in 18-decimal base units.
313    pub initial_allocation_wei: u128,
314    /// Remaining unspent allocation.
315    pub allocation_remaining_wei: u128,
316    /// Cumulative TNZO drawn since bootstrap_start (audit-only).
317    pub total_drawn_wei: u128,
318    pub bootstrap_start: Timestamp,
319    pub bootstrap_end: Timestamp,
320    pub decay_schedule: DecaySchedule,
321    /// Number of SeedAgents currently provisioned (not Terminated).
322    pub seed_agent_count: u32,
323    /// Identifiers of charters under this earmark.
324    pub charter_ids: Vec<Hash>,
325    /// Master kill switch (governance dial). When false, no new agent
326    /// provisioning is admitted and the daemon should wind down.
327    pub enabled: bool,
328    /// Surplus disposition at sunset, in basis points to *burn* (the
329    /// remainder returns to general treasury).
330    pub surplus_burn_bps: u16,
331    /// Index of the month currently being drawn against (0-based from
332    /// `bootstrap_start`). When the observed month advances at refill
333    /// time, `current_month` rolls forward and `month_drawn_wei` resets.
334    pub current_month: u8,
335    /// Cumulative draw within `current_month`, in 18-decimal base units.
336    /// Reset to 0 when `current_month` advances. Bounded above by
337    /// `decay_schedule.cap_for_month(current_month) * seed_agent_count`
338    /// — that is, every active agent may draw the per-month cap once.
339    pub month_drawn_wei: u128,
340}
341
342impl Default for TreasuryEarmark {
343    fn default() -> Self {
344        Self {
345            name: "SeedAgent".to_string(),
346            initial_allocation_wei: 0,
347            allocation_remaining_wei: 0,
348            total_drawn_wei: 0,
349            bootstrap_start: Timestamp::default(),
350            bootstrap_end: Timestamp::default(),
351            decay_schedule: DecaySchedule { points: Vec::new() },
352            seed_agent_count: 0,
353            charter_ids: Vec::new(),
354            enabled: true,
355            surplus_burn_bps: DEFAULT_SURPLUS_BURN_BPS,
356            current_month: 0,
357            month_drawn_wei: 0,
358        }
359    }
360}
361
362impl TreasuryEarmark {
363    /// Compute the 0-indexed month relative to `bootstrap_start` for the
364    /// given timestamp. Saturates at 0 for `now < bootstrap_start` and at
365    /// 255 for very-far-future timestamps. Uses fixed [`MONTH_MILLIS`].
366    pub fn month_index_for(&self, now: Timestamp) -> u8 {
367        let now_ms = now.as_millis();
368        let start_ms = self.bootstrap_start.as_millis();
369        if now_ms <= start_ms {
370            return 0;
371        }
372        let diff = now_ms - start_ms;
373        let months = diff / MONTH_MILLIS;
374        if months > u8::MAX as i64 {
375            u8::MAX
376        } else {
377            months as u8
378        }
379    }
380
381    pub fn validate(&self) -> Result<()> {
382        if self.surplus_burn_bps > 10_000 {
383            return Err(TokenError::InvalidParameter(format!(
384                "surplus_burn_bps {} > 10000",
385                self.surplus_burn_bps
386            )));
387        }
388        if self.allocation_remaining_wei > self.initial_allocation_wei {
389            return Err(TokenError::InvalidParameter(
390                "allocation_remaining > initial_allocation".into(),
391            ));
392        }
393        if self.bootstrap_end.as_millis() < self.bootstrap_start.as_millis() {
394            return Err(TokenError::InvalidParameter(
395                "bootstrap_end before bootstrap_start".into(),
396            ));
397        }
398        // An empty schedule is permitted only at genesis (when no allocation
399        // has been seeded yet).
400        if self.initial_allocation_wei > 0 {
401            self.decay_schedule.validate()?;
402        }
403        Ok(())
404    }
405}
406
407/// Status of an individual SeedAgent in its provisioning lifecycle.
408#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
409pub enum SeedAgentStatus {
410    /// Provisioned and operating per its charter.
411    Active,
412    /// Paused by governance; resumable.
413    Paused,
414    /// Quarantined by governance; existing positions unwind, no new ops.
415    Quarantined,
416    /// Terminated; bond withdrawn, wallet drained, no further activity.
417    Terminated,
418}
419
420impl SeedAgentStatus {
421    pub fn as_str(&self) -> &'static str {
422        match self {
423            SeedAgentStatus::Active => "active",
424            SeedAgentStatus::Paused => "paused",
425            SeedAgentStatus::Quarantined => "quarantined",
426            SeedAgentStatus::Terminated => "terminated",
427        }
428    }
429}
430
431/// Per-agent SeedAgent provisioning record.
432#[derive(Debug, Clone, Serialize, Deserialize)]
433pub struct SeedAgentRecord {
434    /// Machine DID of the SeedAgent.
435    pub agent_did: String,
436    /// Controller (governance) DID — well-known, e.g.
437    /// `did:tenzro:org:treasury:seedagents`.
438    pub controller_did: String,
439    /// Charter under which this agent operates.
440    pub charter_id: Hash,
441    pub status: SeedAgentStatus,
442    /// Cumulative TNZO drawn from earmark allocation for this agent.
443    pub allocation_used_wei: u128,
444    /// Posted bond identifier (link to AgentBond Spec 9). May be unset
445    /// during provisioning before the bond is posted.
446    pub bond_id: Option<Hash>,
447    pub provisioned_at: Timestamp,
448    pub last_active: Timestamp,
449}
450
451impl SeedAgentRecord {
452    pub fn new(
453        agent_did: String,
454        controller_did: String,
455        charter_id: Hash,
456        provisioned_at: Timestamp,
457    ) -> Self {
458        Self {
459            agent_did,
460            controller_did,
461            charter_id,
462            status: SeedAgentStatus::Active,
463            allocation_used_wei: 0,
464            bond_id: None,
465            provisioned_at,
466            last_active: provisioned_at,
467        }
468    }
469}
470
471/// Result of a [`SeedAgentEarmarkManager::refill_agent_monthly`] call.
472///
473/// `granted_wei` is the actually-disbursed amount after clamping against
474/// both the charter's monthly cap and the global decay schedule cap for
475/// the current month. `granted_wei == 0` means the schedule has sunsetted
476/// (month past final point) or the earmark is exhausted/disabled.
477#[derive(Debug, Clone, PartialEq, Eq)]
478pub struct RefillResult {
479    pub agent_did: String,
480    pub requested_wei: u128,
481    pub granted_wei: u128,
482    pub allocation_remaining_wei: u128,
483    pub month: u8,
484    pub schedule_cap_for_month_wei: u128,
485}
486
487/// Disposition of the unspent earmark surplus at hard sunset.
488///
489/// Produced by [`SeedAgentEarmarkManager::dispose_surplus_at_sunset`] when
490/// every charter has sunsetted and no Active/Paused/Quarantined agents
491/// remain. The caller (governance executor or wind-down sweeper) enacts
492/// the on-chain effect: `burn_wei` is removed from circulating supply via
493/// [`crate::tnzo::TnzoToken::burn`]; `treasury_wei` is deposited to the
494/// general [`crate::treasury::NetworkTreasury`] under the TNZO asset.
495///
496/// The manager itself only zeroes `allocation_remaining_wei` and flips
497/// `enabled` to `false` — token-supply effects are caller-owned so this
498/// crate stays free of cycles with the treasury module.
499#[derive(Debug, Clone, PartialEq, Eq)]
500pub struct SurplusDisposition {
501    /// Surplus that was disposed at sunset (`burn_wei + treasury_wei`).
502    pub total_wei: u128,
503    /// Portion to burn (`total * surplus_burn_bps / 10_000`).
504    pub burn_wei: u128,
505    /// Portion to return to the general treasury (`total - burn_wei`).
506    pub treasury_wei: u128,
507    /// `surplus_burn_bps` at the time of disposition (audit).
508    pub surplus_burn_bps: u16,
509}
510
511/// Outcome of one [`SeedAgentEarmarkManager::sunset_wind_down_sweep`]
512/// invocation.
513#[derive(Debug, Clone, Default, PartialEq, Eq)]
514pub struct WindDownReport {
515    /// Agent DIDs transitioned Paused → Quarantined this sweep.
516    pub quarantined: Vec<String>,
517    /// Agent DIDs transitioned Quarantined → Terminated this sweep.
518    pub terminated: Vec<String>,
519    /// Surplus disposition if all charters have sunsetted and no
520    /// non-Terminated agents remain. `None` if the wind-down condition
521    /// has not been reached (or surplus already disposed).
522    pub surplus: Option<SurplusDisposition>,
523}
524
525/// Manages the SeedAgent earmark + charters + per-agent registry, with
526/// optional RocksDB write-through under `CF_TOKENS`.
527///
528/// Construction:
529/// - [`SeedAgentEarmarkManager::new`] — pure in-memory (tests).
530/// - [`SeedAgentEarmarkManager::with_storage`] — write-through and
531///   hydrated from disk on startup.
532pub struct SeedAgentEarmarkManager {
533    earmark: parking_lot::RwLock<TreasuryEarmark>,
534    charters: dashmap::DashMap<Hash, Charter>,
535    agents: dashmap::DashMap<String, SeedAgentRecord>,
536    storage: Option<Arc<dyn KvStore>>,
537}
538
539impl Default for SeedAgentEarmarkManager {
540    fn default() -> Self {
541        Self::new()
542    }
543}
544
545impl SeedAgentEarmarkManager {
546    /// In-memory manager with default genesis earmark (0 allocation).
547    pub fn new() -> Self {
548        Self {
549            earmark: parking_lot::RwLock::new(TreasuryEarmark::default()),
550            charters: dashmap::DashMap::new(),
551            agents: dashmap::DashMap::new(),
552            storage: None,
553        }
554    }
555
556    /// Construct with RocksDB write-through and hydrate the earmark
557    /// singleton, all charters, and all agent records from disk.
558    pub fn with_storage(storage: Arc<dyn KvStore>) -> Result<Self> {
559        let mgr = Self {
560            earmark: parking_lot::RwLock::new(TreasuryEarmark::default()),
561            charters: dashmap::DashMap::new(),
562            agents: dashmap::DashMap::new(),
563            storage: Some(storage.clone()),
564        };
565        mgr.hydrate_from_storage()?;
566        Ok(mgr)
567    }
568
569    /// Read a snapshot of the singleton earmark.
570    pub fn earmark(&self) -> TreasuryEarmark {
571        self.earmark.read().clone()
572    }
573
574    /// Returns the charter with the given id, if present.
575    pub fn get_charter(&self, charter_id: &Hash) -> Option<Charter> {
576        self.charters.get(charter_id).map(|c| c.clone())
577    }
578
579    /// All known charters, ordered by name for stable RPC output.
580    pub fn list_charters(&self) -> Vec<Charter> {
581        let mut v: Vec<Charter> = self.charters.iter().map(|e| e.value().clone()).collect();
582        v.sort_by(|a, b| a.name.cmp(&b.name));
583        v
584    }
585
586    /// Returns the SeedAgent record for `agent_did`, if registered.
587    pub fn get_agent(&self, agent_did: &str) -> Option<SeedAgentRecord> {
588        self.agents.get(agent_did).map(|a| a.clone())
589    }
590
591    /// All SeedAgents, optionally filtered to a single charter.
592    pub fn list_agents(&self, charter_id: Option<&Hash>) -> Vec<SeedAgentRecord> {
593        self.agents
594            .iter()
595            .filter(|e| match charter_id {
596                Some(cid) => &e.value().charter_id == cid,
597                None => true,
598            })
599            .map(|e| e.value().clone())
600            .collect()
601    }
602
603    /// Returns true if `agent_did` is currently a SeedAgent (any status
604    /// except Terminated). Used by the counterparty filter check.
605    pub fn is_seed_agent(&self, agent_did: &str) -> bool {
606        match self.agents.get(agent_did) {
607            Some(a) => !matches!(a.value().status, SeedAgentStatus::Terminated),
608            None => false,
609        }
610    }
611
612    /// Replace the singleton earmark. Validates and persists.
613    pub fn apply_earmark(&self, new: TreasuryEarmark) -> Result<()> {
614        new.validate()?;
615        {
616            let mut guard = self.earmark.write();
617            *guard = new.clone();
618        }
619        self.persist_earmark(&new)?;
620        info!(
621            initial_allocation = new.initial_allocation_wei,
622            charters = new.charter_ids.len(),
623            "SeedAgent earmark updated"
624        );
625        Ok(())
626    }
627
628    /// Add or replace a charter (governance write path).
629    pub fn upsert_charter(&self, charter: Charter) -> Result<()> {
630        charter.validate()?;
631        self.charters
632            .insert(charter.charter_id, charter.clone());
633        self.persist_charter(&charter)?;
634        // Sync charter id list onto the earmark if needed.
635        let needs_sync = {
636            let guard = self.earmark.read();
637            !guard.charter_ids.contains(&charter.charter_id)
638        };
639        if needs_sync {
640            let snapshot = {
641                let mut guard = self.earmark.write();
642                guard.charter_ids.push(charter.charter_id);
643                guard.clone()
644            };
645            self.persist_earmark(&snapshot)?;
646        }
647        debug!(charter = %charter.name, "SeedAgent charter upserted");
648        Ok(())
649    }
650
651    /// Register a SeedAgent under a known charter. Bumps
652    /// `seed_agent_count` on the earmark and persists.
653    pub fn register_agent(&self, record: SeedAgentRecord) -> Result<()> {
654        if !self.charters.contains_key(&record.charter_id) {
655            return Err(TokenError::InvalidParameter(format!(
656                "unknown charter_id {:?}",
657                record.charter_id
658            )));
659        }
660        let new_record = !self.agents.contains_key(&record.agent_did);
661        self.agents
662            .insert(record.agent_did.clone(), record.clone());
663        self.persist_agent(&record)?;
664        if new_record {
665            let snapshot = {
666                let mut guard = self.earmark.write();
667                guard.seed_agent_count = guard.seed_agent_count.saturating_add(1);
668                guard.clone()
669            };
670            self.persist_earmark(&snapshot)?;
671        }
672        debug!(
673            agent = %record.agent_did,
674            charter = ?record.charter_id,
675            "SeedAgent registered"
676        );
677        Ok(())
678    }
679
680    /// Update an agent's status (governance pause/quarantine/terminate
681    /// or daemon-driven wind-down).
682    pub fn set_agent_status(
683        &self,
684        agent_did: &str,
685        status: SeedAgentStatus,
686    ) -> Result<()> {
687        let record = {
688            let mut entry = self
689                .agents
690                .get_mut(agent_did)
691                .ok_or_else(|| TokenError::InvalidParameter(
692                    format!("unknown SeedAgent {}", agent_did),
693                ))?;
694            entry.status = status;
695            entry.last_active = Timestamp::now();
696            entry.value().clone()
697        };
698        self.persist_agent(&record)?;
699        debug!(
700            agent = %agent_did,
701            status = %status.as_str(),
702            "SeedAgent status updated"
703        );
704        Ok(())
705    }
706
707    /// Refill an agent's allocation, enforcing the per-month decay
708    /// schedule and charter caps.
709    ///
710    /// Granted amount = `min(requested_wei, charter.monthly_cap_wei,
711    /// schedule_cap_for_month, earmark.allocation_remaining_wei)`.
712    /// The earmark's `current_month` rolls forward when `now`
713    /// crosses a month boundary; `month_drawn_wei` resets at the
714    /// rollover. The cap is further constrained by the *remaining*
715    /// month-budget across all agents (so multi-agent draws inside
716    /// the same month cannot exceed schedule × seed_agent_count).
717    ///
718    /// Errors:
719    /// - earmark `enabled == false` or `allocation_remaining_wei == 0`
720    /// - agent is not registered or is Terminated
721    /// - the agent's charter is not enabled
722    /// - the agent's charter has been sunsetted (current `now` past
723    ///   `charter.sunset`)
724    ///
725    /// On success, persists both the earmark and the agent record.
726    pub fn refill_agent_monthly(
727        &self,
728        agent_did: &str,
729        requested_wei: u128,
730        now: Timestamp,
731    ) -> Result<RefillResult> {
732        if requested_wei == 0 {
733            return Err(TokenError::InvalidParameter(
734                "refill requested_wei == 0".into(),
735            ));
736        }
737
738        // Look up agent + charter under read locks.
739        let agent_snapshot = self
740            .agents
741            .get(agent_did)
742            .map(|e| e.value().clone())
743            .ok_or_else(|| {
744                TokenError::InvalidParameter(format!(
745                    "unknown SeedAgent {}",
746                    agent_did
747                ))
748            })?;
749        if matches!(agent_snapshot.status, SeedAgentStatus::Terminated) {
750            return Err(TokenError::InvalidParameter(format!(
751                "SeedAgent {} is Terminated",
752                agent_did
753            )));
754        }
755        if !matches!(agent_snapshot.status, SeedAgentStatus::Active) {
756            return Err(TokenError::InvalidParameter(format!(
757                "SeedAgent {} is not Active (status={})",
758                agent_did,
759                agent_snapshot.status.as_str()
760            )));
761        }
762        let charter = self
763            .charters
764            .get(&agent_snapshot.charter_id)
765            .map(|e| e.value().clone())
766            .ok_or_else(|| {
767                TokenError::InvalidParameter(format!(
768                    "SeedAgent {} references unknown charter {:?}",
769                    agent_did, agent_snapshot.charter_id
770                ))
771            })?;
772        if !charter.enabled {
773            return Err(TokenError::InvalidParameter(format!(
774                "charter {:?} is disabled",
775                charter.charter_id
776            )));
777        }
778        if now.as_millis() >= charter.sunset.as_millis()
779            && charter.sunset.as_millis() != 0
780        {
781            return Err(TokenError::InvalidParameter(format!(
782                "charter {:?} has sunsetted",
783                charter.charter_id
784            )));
785        }
786
787        // Mutate the earmark under the write lock: roll month forward
788        // if needed, then compute the granted amount.
789        let (granted, snapshot) = {
790            let mut guard = self.earmark.write();
791            if !guard.enabled {
792                return Err(TokenError::InvalidParameter(
793                    "SeedAgent earmark disabled".into(),
794                ));
795            }
796            if guard.allocation_remaining_wei == 0 {
797                return Err(TokenError::InvalidParameter(
798                    "SeedAgent earmark exhausted".into(),
799                ));
800            }
801
802            // Roll month forward if `now` lands in a later month than
803            // the recorded `current_month`.
804            let observed_month = guard.month_index_for(now);
805            if observed_month > guard.current_month {
806                guard.current_month = observed_month;
807                guard.month_drawn_wei = 0;
808            }
809
810            let schedule_cap = guard
811                .decay_schedule
812                .cap_for_month(guard.current_month);
813            // Per-agent cap = min(schedule, charter monthly cap, requested).
814            let per_agent_cap = schedule_cap
815                .min(charter.spend_caps.monthly_cap_wei)
816                .min(requested_wei);
817
818            // Across all agents this month, the earmark caps the total
819            // monthly draw at `schedule_cap * seed_agent_count` (so each
820            // agent gets its own monthly cap, summed). Compute the
821            // remaining month-budget and clamp to it.
822            let month_budget = schedule_cap
823                .saturating_mul(guard.seed_agent_count as u128);
824            let month_remaining = month_budget
825                .saturating_sub(guard.month_drawn_wei);
826
827            let mut granted = per_agent_cap.min(month_remaining);
828            // Cannot exceed the global allocation remaining.
829            granted = granted.min(guard.allocation_remaining_wei);
830
831            if granted == 0 {
832                let snapshot = RefillResult {
833                    agent_did: agent_did.to_string(),
834                    requested_wei,
835                    granted_wei: 0,
836                    allocation_remaining_wei: guard.allocation_remaining_wei,
837                    month: guard.current_month,
838                    schedule_cap_for_month_wei: schedule_cap,
839                };
840                return Ok(snapshot);
841            }
842
843            guard.allocation_remaining_wei = guard
844                .allocation_remaining_wei
845                .saturating_sub(granted);
846            guard.total_drawn_wei = guard
847                .total_drawn_wei
848                .saturating_add(granted);
849            guard.month_drawn_wei = guard
850                .month_drawn_wei
851                .saturating_add(granted);
852
853            let snapshot = guard.clone();
854            (granted, snapshot)
855        };
856
857        // Persist updated earmark.
858        self.persist_earmark(&snapshot)?;
859
860        // Mutate + persist the agent record.
861        let agent_record = {
862            let mut entry = self
863                .agents
864                .get_mut(agent_did)
865                .ok_or_else(|| TokenError::InvalidParameter(
866                    format!("unknown SeedAgent {}", agent_did),
867                ))?;
868            entry.allocation_used_wei = entry
869                .allocation_used_wei
870                .saturating_add(granted);
871            entry.last_active = now;
872            entry.value().clone()
873        };
874        self.persist_agent(&agent_record)?;
875
876        info!(
877            agent = %agent_did,
878            charter = ?charter.charter_id,
879            requested = requested_wei,
880            granted = granted,
881            month = snapshot.current_month,
882            remaining = snapshot.allocation_remaining_wei,
883            "SeedAgent monthly refill"
884        );
885
886        Ok(RefillResult {
887            agent_did: agent_did.to_string(),
888            requested_wei,
889            granted_wei: granted,
890            allocation_remaining_wei: snapshot.allocation_remaining_wei,
891            month: snapshot.current_month,
892            schedule_cap_for_month_wei: snapshot
893                .decay_schedule
894                .cap_for_month(snapshot.current_month),
895        })
896    }
897
898    /// Single-shot wind-down sweep (Spec 10 Task #44). Pure state-machine —
899    /// no external callbacks. The caller passes `now` (injectable for tests)
900    /// and a `quarantine_grace_ms` cooldown; the manager:
901    ///
902    /// 1. Transitions every `Paused` agent under a sunsetted/disabled
903    ///    charter to `Quarantined`. Records `last_active = now`.
904    /// 2. Transitions every `Quarantined` agent whose `last_active`
905    ///    exceeds `now - quarantine_grace_ms` (i.e. cooldown elapsed) to
906    ///    `Terminated`.
907    /// 3. If **all** charters have sunsetted (`sunset != 0 && now >= sunset`)
908    ///    or are disabled, AND no Active/Paused/Quarantined agents remain
909    ///    after steps 1+2, computes [`SurplusDisposition`] over the
910    ///    earmark's `allocation_remaining_wei`, zeros that field, and
911    ///    flips `enabled = false` on the earmark.
912    ///
913    /// Returns a [`WindDownReport`] describing the transitions + optional
914    /// surplus disposition. The caller (governance executor or daemon
915    /// sunset hook) enacts the on-chain surplus split: burn portion +
916    /// treasury deposit.
917    ///
918    /// Idempotent: a second call after the earmark is sunsetted and zeroed
919    /// returns an empty report. Subsequent quarantine→terminate sweeps
920    /// continue to drain any agents that survived the first pass.
921    pub fn sunset_wind_down_sweep(
922        &self,
923        now: Timestamp,
924        quarantine_grace_ms: i64,
925    ) -> Result<WindDownReport> {
926        let mut report = WindDownReport::default();
927
928        // --- step 1: Paused → Quarantined under sunsetted/disabled charter
929        for agent in self.list_agents(None) {
930            if !matches!(agent.status, SeedAgentStatus::Paused) {
931                continue;
932            }
933            let charter = self.charters.get(&agent.charter_id).map(|e| e.value().clone());
934            let should_quarantine = match charter {
935                None => true, // dangling charter — quarantine defensively
936                Some(c) => {
937                    !c.enabled
938                        || (c.sunset.as_millis() != 0
939                            && now.as_millis() >= c.sunset.as_millis())
940                }
941            };
942            if !should_quarantine {
943                continue;
944            }
945            self.set_agent_status_at(&agent.agent_did, SeedAgentStatus::Quarantined, now)?;
946            report.quarantined.push(agent.agent_did);
947        }
948
949        // --- step 2: Quarantined → Terminated after cooldown
950        for agent in self.list_agents(None) {
951            if !matches!(agent.status, SeedAgentStatus::Quarantined) {
952                continue;
953            }
954            let age = now.as_millis().saturating_sub(agent.last_active.as_millis());
955            if age < quarantine_grace_ms {
956                continue;
957            }
958            self.set_agent_status_at(&agent.agent_did, SeedAgentStatus::Terminated, now)?;
959            // Bump the earmark counter — `seed_agent_count` tracks
960            // *non-Terminated* agents (see field docs).
961            {
962                let snapshot = {
963                    let mut guard = self.earmark.write();
964                    guard.seed_agent_count = guard.seed_agent_count.saturating_sub(1);
965                    guard.clone()
966                };
967                self.persist_earmark(&snapshot)?;
968            }
969            report.terminated.push(agent.agent_did);
970        }
971
972        // --- step 3: dispose surplus iff all charters sunset/disabled
973        //             AND no Active/Paused/Quarantined agents remain.
974        let all_charters_sunset = {
975            let charters = self.list_charters();
976            !charters.is_empty()
977                && charters.iter().all(|c| {
978                    !c.enabled
979                        || (c.sunset.as_millis() != 0
980                            && now.as_millis() >= c.sunset.as_millis())
981                })
982        };
983        let no_live_agents = self.list_agents(None).iter().all(|a| {
984            matches!(a.status, SeedAgentStatus::Terminated)
985        });
986
987        if all_charters_sunset && no_live_agents {
988            let snapshot = self.earmark.read().clone();
989            let remaining = snapshot.allocation_remaining_wei;
990            if remaining > 0 || snapshot.enabled {
991                let burn_bps = snapshot.surplus_burn_bps.min(10_000) as u128;
992                // burn_wei = floor(remaining * burn_bps / 10000).
993                // u128 safe: remaining ≤ initial allocation (genesis bound).
994                let burn = remaining.saturating_mul(burn_bps) / 10_000;
995                let treasury = remaining.saturating_sub(burn);
996                let disposition = SurplusDisposition {
997                    total_wei: remaining,
998                    burn_wei: burn,
999                    treasury_wei: treasury,
1000                    surplus_burn_bps: snapshot.surplus_burn_bps,
1001                };
1002                // Zero the surplus + freeze the earmark.
1003                let new_snapshot = {
1004                    let mut guard = self.earmark.write();
1005                    guard.allocation_remaining_wei = 0;
1006                    guard.enabled = false;
1007                    guard.clone()
1008                };
1009                self.persist_earmark(&new_snapshot)?;
1010                info!(
1011                    total = disposition.total_wei,
1012                    burn = disposition.burn_wei,
1013                    treasury = disposition.treasury_wei,
1014                    burn_bps = disposition.surplus_burn_bps,
1015                    "SeedAgent earmark surplus disposed at sunset"
1016                );
1017                report.surplus = Some(disposition);
1018            }
1019        }
1020
1021        Ok(report)
1022    }
1023
1024    /// Internal: like [`set_agent_status`] but pins `last_active` to the
1025    /// caller-supplied timestamp (the sweep uses this so the quarantine
1026    /// cooldown is anchored to sweep time, not wall clock).
1027    fn set_agent_status_at(
1028        &self,
1029        agent_did: &str,
1030        status: SeedAgentStatus,
1031        now: Timestamp,
1032    ) -> Result<()> {
1033        let record = {
1034            let mut entry = self
1035                .agents
1036                .get_mut(agent_did)
1037                .ok_or_else(|| TokenError::InvalidParameter(
1038                    format!("unknown SeedAgent {}", agent_did),
1039                ))?;
1040            entry.status = status;
1041            entry.last_active = now;
1042            entry.value().clone()
1043        };
1044        self.persist_agent(&record)?;
1045        debug!(
1046            agent = %agent_did,
1047            status = %status.as_str(),
1048            "SeedAgent status updated (sweep)"
1049        );
1050        Ok(())
1051    }
1052
1053    // --- internal: hydration + persistence -------------------------------
1054
1055    fn hydrate_from_storage(&self) -> Result<()> {
1056        let storage = self
1057            .storage
1058            .as_ref()
1059            .expect("hydrate_from_storage requires storage");
1060
1061        // Pre-launch policy: no backcompat shims. If a persisted record
1062        // was written by an older schema and can't deserialize, drop it
1063        // and reseed from defaults. No live users, no data to preserve.
1064
1065        // Earmark singleton.
1066        match storage.get(CF_TOKENS, SEED_EARMARK_KEY)? {
1067            Some(bytes) => match serde_json::from_slice::<TreasuryEarmark>(&bytes) {
1068                Ok(earmark) => {
1069                    *self.earmark.write() = earmark;
1070                }
1071                Err(e) => {
1072                    tracing::warn!(
1073                        target: "tenzro_token::seed_agent",
1074                        error = %e,
1075                        "persisted SeedAgent earmark is from an older schema; reseeding from default"
1076                    );
1077                    let genesis = TreasuryEarmark::default();
1078                    self.persist_earmark(&genesis)?;
1079                }
1080            },
1081            None => {
1082                let genesis = TreasuryEarmark::default();
1083                self.persist_earmark(&genesis)?;
1084            }
1085        }
1086
1087        // Charters.
1088        let charter_keys =
1089            storage.get_keys_with_prefix(CF_TOKENS, SEED_CHARTER_PREFIX)?;
1090        for key in charter_keys {
1091            let Some(bytes) = storage.get(CF_TOKENS, &key)? else { continue };
1092            match serde_json::from_slice::<Charter>(&bytes) {
1093                Ok(charter) => {
1094                    self.charters.insert(charter.charter_id, charter);
1095                }
1096                Err(e) => {
1097                    tracing::warn!(
1098                        target: "tenzro_token::seed_agent",
1099                        key = %String::from_utf8_lossy(&key),
1100                        error = %e,
1101                        "dropping unreadable SeedAgent charter (pre-launch flag-day)"
1102                    );
1103                    storage.write_batch_sync(vec![WriteOp::Delete {
1104                        cf: CF_TOKENS.to_string(),
1105                        key: key.clone(),
1106                    }])?;
1107                }
1108            }
1109        }
1110
1111        // Agents.
1112        let agent_keys =
1113            storage.get_keys_with_prefix(CF_TOKENS, SEED_AGENT_PREFIX)?;
1114        for key in agent_keys {
1115            let Some(bytes) = storage.get(CF_TOKENS, &key)? else { continue };
1116            match serde_json::from_slice::<SeedAgentRecord>(&bytes) {
1117                Ok(record) => {
1118                    self.agents.insert(record.agent_did.clone(), record);
1119                }
1120                Err(e) => {
1121                    tracing::warn!(
1122                        target: "tenzro_token::seed_agent",
1123                        key = %String::from_utf8_lossy(&key),
1124                        error = %e,
1125                        "dropping unreadable SeedAgent record (pre-launch flag-day)"
1126                    );
1127                    storage.write_batch_sync(vec![WriteOp::Delete {
1128                        cf: CF_TOKENS.to_string(),
1129                        key: key.clone(),
1130                    }])?;
1131                }
1132            }
1133        }
1134
1135        info!(
1136            charters = self.charters.len(),
1137            agents = self.agents.len(),
1138            "SeedAgent earmark manager hydrated"
1139        );
1140        Ok(())
1141    }
1142
1143    fn persist_earmark(&self, earmark: &TreasuryEarmark) -> Result<()> {
1144        let Some(storage) = &self.storage else { return Ok(()); };
1145        let bytes = serde_json::to_vec(earmark).map_err(|e| {
1146            TokenError::StorageError(format!("encode SeedAgent earmark: {}", e))
1147        })?;
1148        storage.write_batch_sync(vec![WriteOp::Put {
1149            cf: CF_TOKENS.to_string(),
1150            key: SEED_EARMARK_KEY.to_vec(),
1151            value: bytes,
1152        }])?;
1153        Ok(())
1154    }
1155
1156    fn persist_charter(&self, charter: &Charter) -> Result<()> {
1157        let Some(storage) = &self.storage else { return Ok(()); };
1158        let bytes = serde_json::to_vec(charter).map_err(|e| {
1159            TokenError::StorageError(format!("encode SeedAgent charter: {}", e))
1160        })?;
1161        let mut key = SEED_CHARTER_PREFIX.to_vec();
1162        key.extend_from_slice(charter.charter_id.as_bytes());
1163        storage.write_batch_sync(vec![WriteOp::Put {
1164            cf: CF_TOKENS.to_string(),
1165            key,
1166            value: bytes,
1167        }])?;
1168        Ok(())
1169    }
1170
1171    fn persist_agent(&self, record: &SeedAgentRecord) -> Result<()> {
1172        let Some(storage) = &self.storage else { return Ok(()); };
1173        let bytes = serde_json::to_vec(record).map_err(|e| {
1174            TokenError::StorageError(format!("encode SeedAgent record: {}", e))
1175        })?;
1176        let mut key = SEED_AGENT_PREFIX.to_vec();
1177        key.extend_from_slice(record.agent_did.as_bytes());
1178        storage.write_batch_sync(vec![WriteOp::Put {
1179            cf: CF_TOKENS.to_string(),
1180            key,
1181            value: bytes,
1182        }])?;
1183        Ok(())
1184    }
1185}
1186
1187#[cfg(test)]
1188mod tests {
1189    use super::*;
1190
1191    fn h(byte: u8) -> Hash {
1192        Hash::new([byte; 32])
1193    }
1194
1195    #[test]
1196    fn operation_kind_strings_are_stable() {
1197        assert_eq!(OperationKind::InferenceConsumer.as_str(), "inference_consumer");
1198        assert_eq!(OperationKind::DisputeFiler.as_str(), "dispute_filer");
1199    }
1200
1201    #[test]
1202    fn spend_caps_validate_ordering() {
1203        let bad = SpendCaps {
1204            daily_cap_wei: 5,
1205            monthly_cap_wei: 100,
1206            per_tx_cap_wei: 10, // > daily
1207        };
1208        assert!(bad.validate().is_err());
1209
1210        let good = SpendCaps::default_inference_caps();
1211        assert!(good.validate().is_ok());
1212    }
1213
1214    #[test]
1215    fn decay_schedule_default_sunsets_at_month_12() {
1216        let s = DecaySchedule::default_with_base(1_000);
1217        assert_eq!(s.cap_for_month(0), 1_000);
1218        assert_eq!(s.cap_for_month(3), 750);
1219        assert_eq!(s.cap_for_month(6), 500);
1220        assert_eq!(s.cap_for_month(9), 250);
1221        assert_eq!(s.cap_for_month(12), 0);
1222        assert_eq!(s.cap_for_month(50), 0); // hard sunset
1223        assert!(s.validate().is_ok());
1224    }
1225
1226    #[test]
1227    fn decay_schedule_rejects_non_zero_final() {
1228        let s = DecaySchedule {
1229            points: vec![
1230                DecayPoint { month: 0, max_draw_wei: 1_000 },
1231                DecayPoint { month: 1, max_draw_wei: 500 }, // doesn't end at zero
1232            ],
1233        };
1234        assert!(s.validate().is_err());
1235    }
1236
1237    #[test]
1238    fn earmark_default_is_consistent() {
1239        let e = TreasuryEarmark::default();
1240        assert_eq!(e.surplus_burn_bps, DEFAULT_SURPLUS_BURN_BPS);
1241        assert_eq!(e.initial_allocation_wei, 0);
1242        assert!(e.enabled);
1243        // Empty schedule is permitted at genesis.
1244        assert!(e.validate().is_ok());
1245    }
1246
1247    #[test]
1248    fn manager_upsert_charter_then_register_agent() {
1249        let mgr = SeedAgentEarmarkManager::new();
1250
1251        let charter = Charter {
1252            charter_id: h(0xC1),
1253            name: "InferenceLoad".to_string(),
1254            purpose: "Steady inference load on registered models".to_string(),
1255            operations: vec![OperationKind::InferenceConsumer],
1256            spend_caps: SpendCaps::default_inference_caps(),
1257            target_throughput: Some(TargetThroughput { ops_per_sec_milli: 167 }),
1258            counterparty_filter: CounterpartyFilter::default(),
1259            sunset: Timestamp::new(1_000_000),
1260            enabled: true,
1261        };
1262        mgr.upsert_charter(charter.clone()).unwrap();
1263        assert_eq!(mgr.list_charters().len(), 1);
1264        assert_eq!(mgr.get_charter(&h(0xC1)).unwrap().name, "InferenceLoad");
1265
1266        // Earmark.charter_ids syncs.
1267        assert_eq!(mgr.earmark().charter_ids.len(), 1);
1268
1269        // Register an agent under this charter.
1270        let agent = SeedAgentRecord::new(
1271            "did:tenzro:machine:seed:001".to_string(),
1272            "did:tenzro:org:treasury:seedagents".to_string(),
1273            h(0xC1),
1274            Timestamp::new(1_000),
1275        );
1276        mgr.register_agent(agent.clone()).unwrap();
1277        assert_eq!(mgr.earmark().seed_agent_count, 1);
1278        assert!(mgr.is_seed_agent("did:tenzro:machine:seed:001"));
1279
1280        // Re-registering the same DID does not double-bump the count.
1281        mgr.register_agent(agent).unwrap();
1282        assert_eq!(mgr.earmark().seed_agent_count, 1);
1283    }
1284
1285    #[test]
1286    fn manager_register_unknown_charter_fails() {
1287        let mgr = SeedAgentEarmarkManager::new();
1288        let agent = SeedAgentRecord::new(
1289            "did:tenzro:machine:seed:002".to_string(),
1290            "did:tenzro:org:treasury:seedagents".to_string(),
1291            h(0xFF),
1292            Timestamp::new(1_000),
1293        );
1294        assert!(mgr.register_agent(agent).is_err());
1295    }
1296
1297    #[test]
1298    fn manager_set_agent_status_terminated_drops_seed_flag() {
1299        let mgr = SeedAgentEarmarkManager::new();
1300
1301        let charter = Charter {
1302            charter_id: h(0xC2),
1303            name: "BridgeProbe".to_string(),
1304            purpose: "Quarterly small TNZO bridge probes".to_string(),
1305            operations: vec![OperationKind::BridgeUser],
1306            spend_caps: SpendCaps::default_inference_caps(),
1307            target_throughput: None,
1308            counterparty_filter: CounterpartyFilter::default(),
1309            sunset: Timestamp::new(2_000_000),
1310            enabled: true,
1311        };
1312        mgr.upsert_charter(charter).unwrap();
1313
1314        let agent = SeedAgentRecord::new(
1315            "did:tenzro:machine:seed:bridge".to_string(),
1316            "did:tenzro:org:treasury:seedagents".to_string(),
1317            h(0xC2),
1318            Timestamp::new(1_000),
1319        );
1320        mgr.register_agent(agent).unwrap();
1321        assert!(mgr.is_seed_agent("did:tenzro:machine:seed:bridge"));
1322
1323        mgr.set_agent_status(
1324            "did:tenzro:machine:seed:bridge",
1325            SeedAgentStatus::Terminated,
1326        )
1327        .unwrap();
1328        // Terminated agents are no longer counterparty-filter-active.
1329        assert!(!mgr.is_seed_agent("did:tenzro:machine:seed:bridge"));
1330    }
1331
1332    #[test]
1333    fn earmark_validates_allocation_invariants() {
1334        let mut e = TreasuryEarmark {
1335            initial_allocation_wei: 100,
1336            allocation_remaining_wei: 200, // > initial
1337            ..TreasuryEarmark::default()
1338        };
1339        assert!(e.validate().is_err());
1340
1341        e.allocation_remaining_wei = 50;
1342        e.surplus_burn_bps = 10_001; // > 100%
1343        assert!(e.validate().is_err());
1344
1345        e.surplus_burn_bps = 5_000;
1346        e.bootstrap_start = Timestamp::new(2_000);
1347        e.bootstrap_end = Timestamp::new(1_000); // before start
1348        assert!(e.validate().is_err());
1349
1350        e.bootstrap_end = Timestamp::new(3_000);
1351        e.decay_schedule = DecaySchedule::default_with_base(10);
1352        assert!(e.validate().is_ok());
1353    }
1354
1355    fn seeded_manager_for_refill() -> SeedAgentEarmarkManager {
1356        let mgr = SeedAgentEarmarkManager::new();
1357
1358        // Allocation: 10_000 TNZO base units; base monthly draw 100.
1359        // Default schedule: month 0..=2 → 100, 3..=5 → 75, 6..=8 → 50,
1360        // 9..=11 → 25, 12 → 0.
1361        let earmark = TreasuryEarmark {
1362            name: "SeedAgent".to_string(),
1363            initial_allocation_wei: 10_000,
1364            allocation_remaining_wei: 10_000,
1365            total_drawn_wei: 0,
1366            bootstrap_start: Timestamp::new(0),
1367            bootstrap_end: Timestamp::new(MONTH_MILLIS * 12),
1368            decay_schedule: DecaySchedule::default_with_base(100),
1369            seed_agent_count: 0,
1370            charter_ids: Vec::new(),
1371            enabled: true,
1372            surplus_burn_bps: DEFAULT_SURPLUS_BURN_BPS,
1373            current_month: 0,
1374            month_drawn_wei: 0,
1375        };
1376        mgr.apply_earmark(earmark).unwrap();
1377
1378        let charter = Charter {
1379            charter_id: h(0xC1),
1380            name: "InferenceLoad".to_string(),
1381            purpose: "Steady inference load".to_string(),
1382            operations: vec![OperationKind::InferenceConsumer],
1383            spend_caps: SpendCaps {
1384                daily_cap_wei: 50,
1385                monthly_cap_wei: 1_000,
1386                per_tx_cap_wei: 10,
1387            },
1388            target_throughput: None,
1389            counterparty_filter: CounterpartyFilter::default(),
1390            sunset: Timestamp::new(MONTH_MILLIS * 12),
1391            enabled: true,
1392        };
1393        mgr.upsert_charter(charter).unwrap();
1394
1395        // Two agents under the same charter.
1396        for did in ["did:tenzro:machine:seed:a", "did:tenzro:machine:seed:b"] {
1397            mgr.register_agent(SeedAgentRecord::new(
1398                did.to_string(),
1399                "did:tenzro:org:treasury:seedagents".into(),
1400                h(0xC1),
1401                Timestamp::new(0),
1402            ))
1403            .unwrap();
1404        }
1405        mgr
1406    }
1407
1408    #[test]
1409    fn refill_clamps_to_schedule_in_month_0() {
1410        let mgr = seeded_manager_for_refill();
1411        // Month 0: schedule cap = 100. Charter monthly cap = 1_000.
1412        // Request 80 → granted 80. Request 90 → only 20 left in the
1413        // per-agent slot (already used 80) but the global month_budget
1414        // is schedule_cap × seed_agent_count = 200, of which 80 used →
1415        // 120 remaining. So second agent can still draw up to charter
1416        // monthly_cap; the constraint is global.
1417        let r1 = mgr
1418            .refill_agent_monthly("did:tenzro:machine:seed:a", 80, Timestamp::new(0))
1419            .unwrap();
1420        assert_eq!(r1.granted_wei, 80);
1421        assert_eq!(r1.month, 0);
1422        assert_eq!(r1.schedule_cap_for_month_wei, 100);
1423
1424        // Same agent requests 90 again — clamped by remaining month budget
1425        // (200 - 80 = 120) and by schedule cap per-agent (100). per_agent_cap
1426        // = min(100, 1000, 90) = 90. month_remaining = 120. granted = 90.
1427        let r2 = mgr
1428            .refill_agent_monthly("did:tenzro:machine:seed:a", 90, Timestamp::new(0))
1429            .unwrap();
1430        assert_eq!(r2.granted_wei, 90);
1431    }
1432
1433    #[test]
1434    fn refill_clamps_to_charter_monthly_cap() {
1435        let mgr = seeded_manager_for_refill();
1436        // Charter monthly_cap = 1_000 — requested 2_000 clamped to schedule
1437        // (100) first, well below charter cap. Use a larger schedule by
1438        // advancing to a far-future test scenario: instead, prove charter
1439        // cap binds when smaller than schedule × seed_agent_count.
1440        // Tighten charter monthly_cap to 30 and re-upsert.
1441        let mut charter = mgr.get_charter(&h(0xC1)).unwrap();
1442        charter.spend_caps.monthly_cap_wei = 30;
1443        charter.spend_caps.daily_cap_wei = 30;
1444        charter.spend_caps.per_tx_cap_wei = 30;
1445        mgr.upsert_charter(charter).unwrap();
1446
1447        let r = mgr
1448            .refill_agent_monthly(
1449                "did:tenzro:machine:seed:a",
1450                500,
1451                Timestamp::new(0),
1452            )
1453            .unwrap();
1454        assert_eq!(r.granted_wei, 30);
1455    }
1456
1457    #[test]
1458    fn refill_rolls_month_forward() {
1459        let mgr = seeded_manager_for_refill();
1460        // Draw at month 0.
1461        let r0 = mgr
1462            .refill_agent_monthly("did:tenzro:machine:seed:a", 50, Timestamp::new(0))
1463            .unwrap();
1464        assert_eq!(r0.month, 0);
1465        // Advance to month 3 (schedule cap = 75).
1466        let later = Timestamp::new(MONTH_MILLIS * 3);
1467        let r3 = mgr
1468            .refill_agent_monthly("did:tenzro:machine:seed:a", 100, later)
1469            .unwrap();
1470        assert_eq!(r3.month, 3);
1471        assert_eq!(r3.schedule_cap_for_month_wei, 75);
1472        // per_agent_cap = min(75, 1000, 100) = 75. month_drawn_wei was
1473        // reset on the rollover, so granted = 75.
1474        assert_eq!(r3.granted_wei, 75);
1475    }
1476
1477    #[test]
1478    fn refill_after_sunset_grants_zero() {
1479        let mgr = seeded_manager_for_refill();
1480        // Month 12 → schedule cap 0. Charter sunset is at month 12, so
1481        // the charter rejects refills past sunset. Move now to month
1482        // 11.5 to keep before sunset but on the 0-cap month edge: use
1483        // month 13 to test schedule-zero outside charter sunset. Instead,
1484        // bump charter sunset further.
1485        let mut charter = mgr.get_charter(&h(0xC1)).unwrap();
1486        charter.sunset = Timestamp::new(MONTH_MILLIS * 24);
1487        mgr.upsert_charter(charter).unwrap();
1488
1489        let later = Timestamp::new(MONTH_MILLIS * 12);
1490        let r = mgr
1491            .refill_agent_monthly("did:tenzro:machine:seed:a", 100, later)
1492            .unwrap();
1493        assert_eq!(r.granted_wei, 0);
1494        assert_eq!(r.schedule_cap_for_month_wei, 0);
1495    }
1496
1497    #[test]
1498    fn refill_rejects_disabled_charter() {
1499        let mgr = seeded_manager_for_refill();
1500        let mut charter = mgr.get_charter(&h(0xC1)).unwrap();
1501        charter.enabled = false;
1502        mgr.upsert_charter(charter).unwrap();
1503
1504        let err = mgr
1505            .refill_agent_monthly("did:tenzro:machine:seed:a", 10, Timestamp::new(0))
1506            .unwrap_err();
1507        match err {
1508            TokenError::InvalidParameter(msg) => {
1509                assert!(msg.contains("disabled"), "{}", msg);
1510            }
1511            other => panic!("unexpected err: {:?}", other),
1512        }
1513    }
1514
1515    #[test]
1516    fn refill_rejects_paused_or_terminated_agent() {
1517        let mgr = seeded_manager_for_refill();
1518        mgr.set_agent_status(
1519            "did:tenzro:machine:seed:a",
1520            SeedAgentStatus::Paused,
1521        )
1522        .unwrap();
1523        assert!(mgr
1524            .refill_agent_monthly("did:tenzro:machine:seed:a", 10, Timestamp::new(0))
1525            .is_err());
1526
1527        mgr.set_agent_status(
1528            "did:tenzro:machine:seed:b",
1529            SeedAgentStatus::Terminated,
1530        )
1531        .unwrap();
1532        assert!(mgr
1533            .refill_agent_monthly("did:tenzro:machine:seed:b", 10, Timestamp::new(0))
1534            .is_err());
1535    }
1536
1537    #[test]
1538    fn refill_drains_allocation_remaining() {
1539        let mgr = SeedAgentEarmarkManager::new();
1540        let earmark = TreasuryEarmark {
1541            name: "SeedAgent".into(),
1542            initial_allocation_wei: 50,
1543            allocation_remaining_wei: 50,
1544            total_drawn_wei: 0,
1545            bootstrap_start: Timestamp::new(0),
1546            bootstrap_end: Timestamp::new(MONTH_MILLIS * 12),
1547            decay_schedule: DecaySchedule::default_with_base(1_000),
1548            seed_agent_count: 0,
1549            charter_ids: Vec::new(),
1550            enabled: true,
1551            surplus_burn_bps: DEFAULT_SURPLUS_BURN_BPS,
1552            current_month: 0,
1553            month_drawn_wei: 0,
1554        };
1555        mgr.apply_earmark(earmark).unwrap();
1556        mgr.upsert_charter(Charter {
1557            charter_id: h(0xC1),
1558            name: "T".into(),
1559            purpose: "T".into(),
1560            operations: vec![OperationKind::InferenceConsumer],
1561            spend_caps: SpendCaps {
1562                daily_cap_wei: 1_000,
1563                monthly_cap_wei: 1_000,
1564                per_tx_cap_wei: 1_000,
1565            },
1566            target_throughput: None,
1567            counterparty_filter: CounterpartyFilter::default(),
1568            sunset: Timestamp::new(MONTH_MILLIS * 12),
1569            enabled: true,
1570        })
1571        .unwrap();
1572        mgr.register_agent(SeedAgentRecord::new(
1573            "did:tenzro:machine:seed:x".into(),
1574            "did:tenzro:org:treasury:seedagents".into(),
1575            h(0xC1),
1576            Timestamp::new(0),
1577        ))
1578        .unwrap();
1579
1580        // Schedule cap month 0 = 1_000, but allocation remaining only 50.
1581        let r = mgr
1582            .refill_agent_monthly("did:tenzro:machine:seed:x", 200, Timestamp::new(0))
1583            .unwrap();
1584        assert_eq!(r.granted_wei, 50);
1585        assert_eq!(r.allocation_remaining_wei, 0);
1586
1587        // Subsequent refill → exhausted error.
1588        let err = mgr
1589            .refill_agent_monthly("did:tenzro:machine:seed:x", 1, Timestamp::new(0))
1590            .unwrap_err();
1591        match err {
1592            TokenError::InvalidParameter(msg) => {
1593                assert!(msg.contains("exhausted"), "{}", msg);
1594            }
1595            other => panic!("unexpected err: {:?}", other),
1596        }
1597    }
1598
1599    #[test]
1600    fn list_agents_filters_by_charter() {
1601        let mgr = SeedAgentEarmarkManager::new();
1602        for (cid, name) in [(0xA1u8, "A"), (0xA2u8, "B")] {
1603            mgr.upsert_charter(Charter {
1604                charter_id: h(cid),
1605                name: name.to_string(),
1606                purpose: "test".into(),
1607                operations: vec![OperationKind::InferenceConsumer],
1608                spend_caps: SpendCaps::default_inference_caps(),
1609                target_throughput: None,
1610                counterparty_filter: CounterpartyFilter::default(),
1611                sunset: Timestamp::new(0),
1612                enabled: true,
1613            })
1614            .unwrap();
1615        }
1616
1617        for (i, cid) in [(1, 0xA1u8), (2, 0xA1u8), (3, 0xA2u8)].iter() {
1618            mgr.register_agent(SeedAgentRecord::new(
1619                format!("did:tenzro:machine:seed:{}", i),
1620                "did:tenzro:org:treasury:seedagents".into(),
1621                h(*cid),
1622                Timestamp::new(0),
1623            ))
1624            .unwrap();
1625        }
1626
1627        assert_eq!(mgr.list_agents(None).len(), 3);
1628        assert_eq!(mgr.list_agents(Some(&h(0xA1))).len(), 2);
1629        assert_eq!(mgr.list_agents(Some(&h(0xA2))).len(), 1);
1630    }
1631
1632    /// Build a manager with a sunsettable charter at `charter_sunset` and
1633    /// `n` agents under it, all `Paused` at `t0`. Used by the wind-down
1634    /// sweep tests.
1635    fn seeded_manager_for_sweep(
1636        n: usize,
1637        charter_sunset: Timestamp,
1638        charter_enabled: bool,
1639    ) -> SeedAgentEarmarkManager {
1640        let mgr = SeedAgentEarmarkManager::new();
1641        let earmark = TreasuryEarmark {
1642            name: "SeedAgent".into(),
1643            initial_allocation_wei: 1_000,
1644            allocation_remaining_wei: 1_000,
1645            total_drawn_wei: 0,
1646            bootstrap_start: Timestamp::new(0),
1647            bootstrap_end: Timestamp::new(MONTH_MILLIS * 12),
1648            decay_schedule: DecaySchedule::default_with_base(100),
1649            seed_agent_count: 0,
1650            charter_ids: Vec::new(),
1651            enabled: true,
1652            surplus_burn_bps: DEFAULT_SURPLUS_BURN_BPS,
1653            current_month: 0,
1654            month_drawn_wei: 0,
1655        };
1656        mgr.apply_earmark(earmark).unwrap();
1657        mgr.upsert_charter(Charter {
1658            charter_id: h(0xC1),
1659            name: "SunsetCharter".into(),
1660            purpose: "test".into(),
1661            operations: vec![OperationKind::InferenceConsumer],
1662            spend_caps: SpendCaps::default_inference_caps(),
1663            target_throughput: None,
1664            counterparty_filter: CounterpartyFilter::default(),
1665            sunset: charter_sunset,
1666            enabled: charter_enabled,
1667        })
1668        .unwrap();
1669        for i in 0..n {
1670            let did = format!("did:tenzro:machine:seed:s{}", i);
1671            mgr.register_agent(SeedAgentRecord::new(
1672                did.clone(),
1673                "did:tenzro:org:treasury:seedagents".into(),
1674                h(0xC1),
1675                Timestamp::new(0),
1676            ))
1677            .unwrap();
1678            mgr.set_agent_status(&did, SeedAgentStatus::Paused).unwrap();
1679        }
1680        mgr
1681    }
1682
1683    #[test]
1684    fn sweep_paused_to_quarantined_under_sunsetted_charter() {
1685        // Charter sunset at t=1000; sweep at t=1500.
1686        let mgr = seeded_manager_for_sweep(2, Timestamp::new(1_000), true);
1687        let now = Timestamp::new(1_500);
1688        let report = mgr.sunset_wind_down_sweep(now, DEFAULT_QUARANTINE_GRACE_MS).unwrap();
1689        assert_eq!(report.quarantined.len(), 2);
1690        assert_eq!(report.terminated.len(), 0);
1691        assert!(report.surplus.is_none(), "still inside grace window");
1692        for a in mgr.list_agents(None) {
1693            assert!(matches!(a.status, SeedAgentStatus::Quarantined));
1694        }
1695    }
1696
1697    #[test]
1698    fn sweep_paused_to_quarantined_under_disabled_charter() {
1699        // Charter sunset in the future, but disabled — still quarantines.
1700        let mgr = seeded_manager_for_sweep(1, Timestamp::new(10_000_000), false);
1701        let now = Timestamp::new(1_500);
1702        let report = mgr.sunset_wind_down_sweep(now, DEFAULT_QUARANTINE_GRACE_MS).unwrap();
1703        assert_eq!(report.quarantined.len(), 1);
1704    }
1705
1706    #[test]
1707    fn sweep_quarantined_to_terminated_after_grace() {
1708        let mgr = seeded_manager_for_sweep(1, Timestamp::new(1_000), true);
1709        // First sweep: Paused → Quarantined at t=1_500.
1710        let t1 = Timestamp::new(1_500);
1711        let r1 = mgr.sunset_wind_down_sweep(t1, DEFAULT_QUARANTINE_GRACE_MS).unwrap();
1712        assert_eq!(r1.quarantined.len(), 1);
1713        assert_eq!(mgr.earmark().seed_agent_count, 1);
1714
1715        // Second sweep before grace elapses: still Quarantined.
1716        let t2 = Timestamp::new(1_500 + DEFAULT_QUARANTINE_GRACE_MS - 1);
1717        let r2 = mgr.sunset_wind_down_sweep(t2, DEFAULT_QUARANTINE_GRACE_MS).unwrap();
1718        assert!(r2.terminated.is_empty());
1719
1720        // Third sweep after grace: Terminated, surplus disposed, earmark frozen.
1721        let t3 = Timestamp::new(1_500 + DEFAULT_QUARANTINE_GRACE_MS);
1722        let r3 = mgr.sunset_wind_down_sweep(t3, DEFAULT_QUARANTINE_GRACE_MS).unwrap();
1723        assert_eq!(r3.terminated.len(), 1);
1724        assert_eq!(mgr.earmark().seed_agent_count, 0);
1725        // All charters sunset + no live agents → surplus path triggers.
1726        let disposition = r3.surplus.expect("surplus disposition must fire");
1727        // Default 50/50 split on 1_000.
1728        assert_eq!(disposition.total_wei, 1_000);
1729        assert_eq!(disposition.burn_wei, 500);
1730        assert_eq!(disposition.treasury_wei, 500);
1731        assert_eq!(mgr.earmark().allocation_remaining_wei, 0);
1732        assert!(!mgr.earmark().enabled);
1733    }
1734
1735    #[test]
1736    fn sweep_idempotent_after_sunset_completes() {
1737        let mgr = seeded_manager_for_sweep(1, Timestamp::new(1_000), true);
1738        let t_far = Timestamp::new(1_500 + DEFAULT_QUARANTINE_GRACE_MS);
1739        // First sweep: Paused → Quarantined.
1740        mgr.sunset_wind_down_sweep(Timestamp::new(1_500), DEFAULT_QUARANTINE_GRACE_MS)
1741            .unwrap();
1742        // Second sweep: Quarantined → Terminated + surplus disposed.
1743        let r1 = mgr.sunset_wind_down_sweep(t_far, DEFAULT_QUARANTINE_GRACE_MS).unwrap();
1744        assert!(r1.surplus.is_some());
1745        // Third sweep: nothing left to do.
1746        let r2 = mgr.sunset_wind_down_sweep(t_far, DEFAULT_QUARANTINE_GRACE_MS).unwrap();
1747        assert!(r2.quarantined.is_empty());
1748        assert!(r2.terminated.is_empty());
1749        assert!(r2.surplus.is_none(), "surplus already disposed, earmark frozen");
1750    }
1751
1752    #[test]
1753    fn sweep_no_surplus_when_some_charter_still_live() {
1754        let mgr = seeded_manager_for_sweep(1, Timestamp::new(1_000), true);
1755        // Add a second charter that is NOT sunsetted.
1756        mgr.upsert_charter(Charter {
1757            charter_id: h(0xC2),
1758            name: "Live".into(),
1759            purpose: "live charter".into(),
1760            operations: vec![OperationKind::InferenceConsumer],
1761            spend_caps: SpendCaps::default_inference_caps(),
1762            target_throughput: None,
1763            counterparty_filter: CounterpartyFilter::default(),
1764            // Well past `now + grace` so C2 remains live during the sweep.
1765            sunset: Timestamp::new(i64::MAX / 2),
1766            enabled: true,
1767        })
1768        .unwrap();
1769        // First sweep quarantines the agent under the sunsetted C1 (anchors
1770        // last_active = sweep-time). Second sweep beyond grace terminates it.
1771        let t1 = Timestamp::new(1_500);
1772        let _ = mgr.sunset_wind_down_sweep(t1, DEFAULT_QUARANTINE_GRACE_MS).unwrap();
1773        let t2 = Timestamp::new(1_500 + DEFAULT_QUARANTINE_GRACE_MS);
1774        let report = mgr.sunset_wind_down_sweep(t2, DEFAULT_QUARANTINE_GRACE_MS).unwrap();
1775        assert_eq!(report.terminated.len(), 1);
1776        // Live charter still around → no surplus disposition.
1777        assert!(report.surplus.is_none());
1778        assert_eq!(mgr.earmark().allocation_remaining_wei, 1_000);
1779        assert!(mgr.earmark().enabled);
1780    }
1781
1782    #[test]
1783    fn sweep_surplus_split_respects_burn_bps() {
1784        let mgr = SeedAgentEarmarkManager::new();
1785        // Bias the surplus disposition: 80% burn, 20% treasury.
1786        let earmark = TreasuryEarmark {
1787            name: "SeedAgent".into(),
1788            initial_allocation_wei: 1_000,
1789            allocation_remaining_wei: 1_000,
1790            total_drawn_wei: 0,
1791            bootstrap_start: Timestamp::new(0),
1792            bootstrap_end: Timestamp::new(MONTH_MILLIS * 12),
1793            decay_schedule: DecaySchedule::default_with_base(100),
1794            seed_agent_count: 0,
1795            charter_ids: Vec::new(),
1796            enabled: true,
1797            surplus_burn_bps: 8_000,
1798            current_month: 0,
1799            month_drawn_wei: 0,
1800        };
1801        mgr.apply_earmark(earmark).unwrap();
1802        mgr.upsert_charter(Charter {
1803            charter_id: h(0xC1),
1804            name: "Sunsets".into(),
1805            purpose: "p".into(),
1806            operations: vec![OperationKind::InferenceConsumer],
1807            spend_caps: SpendCaps::default_inference_caps(),
1808            target_throughput: None,
1809            counterparty_filter: CounterpartyFilter::default(),
1810            sunset: Timestamp::new(1),
1811            enabled: true,
1812        })
1813        .unwrap();
1814        // No agents → step 1+2 are no-ops, step 3 fires immediately.
1815        let r = mgr
1816            .sunset_wind_down_sweep(Timestamp::new(2), DEFAULT_QUARANTINE_GRACE_MS)
1817            .unwrap();
1818        let d = r.surplus.expect("must dispose");
1819        assert_eq!(d.burn_wei, 800);
1820        assert_eq!(d.treasury_wei, 200);
1821        assert_eq!(d.surplus_burn_bps, 8_000);
1822    }
1823}