1use 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
49pub const SEED_EARMARK_KEY: &[u8] = b"seed_earmark:singleton";
51pub const SEED_CHARTER_PREFIX: &[u8] = b"seed_charter:";
53pub const SEED_AGENT_PREFIX: &[u8] = b"seed_agent:";
55
56pub const DEFAULT_BOOTSTRAP_MONTHS: u8 = 12;
58
59pub const DEFAULT_SURPLUS_BURN_BPS: u16 = 5000;
64
65pub const DEFAULT_QUARANTINE_GRACE_MS: i64 = 7 * 86_400_000;
70
71pub const MONTH_MILLIS: i64 = 30 * 86_400_000;
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
82pub enum OperationKind {
83 InferenceConsumer,
85 TaskMarketplaceConsumer,
87 TemplateInstantiator,
89 BridgeUser,
91 SettlementProbe,
93 Settler7683Probe,
95 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
118pub struct DecayPoint {
119 pub month: u8,
120 pub max_draw_wei: u128,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
130pub struct SpendCaps {
131 pub daily_cap_wei: u128,
133 pub monthly_cap_wei: u128,
135 pub per_tx_cap_wei: u128,
137}
138
139impl SpendCaps {
140 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
168pub struct TargetThroughput {
169 pub ops_per_sec_milli: u32,
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
182pub struct CounterpartyFilter {
183 pub deny_other_seed_agents: bool,
186 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#[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 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
240pub struct DecaySchedule {
241 pub points: Vec<DecayPoint>,
242}
243
244impl DecaySchedule {
245 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 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 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
310pub struct TreasuryEarmark {
311 pub name: String,
312 pub initial_allocation_wei: u128,
314 pub allocation_remaining_wei: u128,
316 pub total_drawn_wei: u128,
318 pub bootstrap_start: Timestamp,
319 pub bootstrap_end: Timestamp,
320 pub decay_schedule: DecaySchedule,
321 pub seed_agent_count: u32,
323 pub charter_ids: Vec<Hash>,
325 pub enabled: bool,
328 pub surplus_burn_bps: u16,
331 pub current_month: u8,
335 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 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 if self.initial_allocation_wei > 0 {
401 self.decay_schedule.validate()?;
402 }
403 Ok(())
404 }
405}
406
407#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
409pub enum SeedAgentStatus {
410 Active,
412 Paused,
414 Quarantined,
416 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#[derive(Debug, Clone, Serialize, Deserialize)]
433pub struct SeedAgentRecord {
434 pub agent_did: String,
436 pub controller_did: String,
439 pub charter_id: Hash,
441 pub status: SeedAgentStatus,
442 pub allocation_used_wei: u128,
444 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#[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#[derive(Debug, Clone, PartialEq, Eq)]
500pub struct SurplusDisposition {
501 pub total_wei: u128,
503 pub burn_wei: u128,
505 pub treasury_wei: u128,
507 pub surplus_burn_bps: u16,
509}
510
511#[derive(Debug, Clone, Default, PartialEq, Eq)]
514pub struct WindDownReport {
515 pub quarantined: Vec<String>,
517 pub terminated: Vec<String>,
519 pub surplus: Option<SurplusDisposition>,
523}
524
525pub 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 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 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 pub fn earmark(&self) -> TreasuryEarmark {
571 self.earmark.read().clone()
572 }
573
574 pub fn get_charter(&self, charter_id: &Hash) -> Option<Charter> {
576 self.charters.get(charter_id).map(|c| c.clone())
577 }
578
579 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 pub fn get_agent(&self, agent_did: &str) -> Option<SeedAgentRecord> {
588 self.agents.get(agent_did).map(|a| a.clone())
589 }
590
591 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 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 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 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 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 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 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 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 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 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 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 let per_agent_cap = schedule_cap
815 .min(charter.spend_caps.monthly_cap_wei)
816 .min(requested_wei);
817
818 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 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 self.persist_earmark(&snapshot)?;
859
860 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 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 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, 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 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 {
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 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 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 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 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 fn hydrate_from_storage(&self) -> Result<()> {
1056 let storage = self
1057 .storage
1058 .as_ref()
1059 .expect("hydrate_from_storage requires storage");
1060
1061 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 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 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, };
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); 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 }, ],
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 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 assert_eq!(mgr.earmark().charter_ids.len(), 1);
1268
1269 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 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 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, ..TreasuryEarmark::default()
1338 };
1339 assert!(e.validate().is_err());
1340
1341 e.allocation_remaining_wei = 50;
1342 e.surplus_burn_bps = 10_001; 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); 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let disposition = r3.surplus.expect("surplus disposition must fire");
1727 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 mgr.sunset_wind_down_sweep(Timestamp::new(1_500), DEFAULT_QUARANTINE_GRACE_MS)
1741 .unwrap();
1742 let r1 = mgr.sunset_wind_down_sweep(t_far, DEFAULT_QUARANTINE_GRACE_MS).unwrap();
1744 assert!(r1.surplus.is_some());
1745 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 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 sunset: Timestamp::new(i64::MAX / 2),
1766 enabled: true,
1767 })
1768 .unwrap();
1769 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 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 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 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}