lean_ctx_protocol/hive/
optin.rs1use serde::{Deserialize, Serialize};
40
41use super::messages::NetworkV1;
42use super::{require_schema, validate_id};
43use crate::{UtcTimestamp, ValidationError};
44
45pub const ENGINE_OPTIN_SCHEMA: &str = "leanctx.hive.engine-optin/v1";
46pub const ENGINE_OPTIN_STOP_SCHEMA: &str = "leanctx.hive.engine-optin-stop/v1";
47pub const DEFAULT_DAILY_TASKS: u32 = 5;
48pub const MAX_DAILY_TASKS: u32 = 100;
49pub const MAX_OPTIN_VAULTS: usize = 200;
50pub const MAX_OPTIN_NETWORKS: usize = 200;
51pub const MAX_OPTIN_TOPICS: usize = 5;
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
56pub struct DailyBudgetV1 {
57 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub tasks: Option<u32>,
60 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub tokens: Option<u64>,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub usd_micros: Option<u64>,
64}
65
66impl Default for DailyBudgetV1 {
67 fn default() -> Self {
68 Self {
69 tasks: Some(DEFAULT_DAILY_TASKS),
70 tokens: None,
71 usd_micros: None,
72 }
73 }
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78pub struct DailyUsageV1 {
79 pub day: String,
81 pub tasks: u32,
82 pub tokens: u64,
83 pub usd_micros: u64,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88pub struct EngineOptinV1 {
89 pub schema: String,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub optin_id: Option<String>,
94 pub active: bool,
95 #[serde(default)]
97 pub vaults: Vec<String>,
98 #[serde(default)]
101 pub networks: Vec<NetworkV1>,
102 #[serde(default)]
104 pub topics: Vec<String>,
105 pub daily_budget: DailyBudgetV1,
106 pub used_today: DailyUsageV1,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub stopped_at: Option<UtcTimestamp>,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub updated_at: Option<UtcTimestamp>,
111}
112
113fn topic_ok(topic: &str) -> bool {
114 (2..=32).contains(&topic.len())
115 && topic
116 .bytes()
117 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
118}
119
120fn day_ok(day: &str) -> bool {
121 let b = day.as_bytes();
122 b.len() == 10
123 && b[4] == b'-'
124 && b[7] == b'-'
125 && [0..4, 5..7, 8..10]
126 .iter()
127 .all(|r| b[r.clone()].iter().all(u8::is_ascii_digit))
128}
129
130impl EngineOptinV1 {
131 pub fn validate(&self) -> Result<(), ValidationError> {
132 require_schema(&self.schema, ENGINE_OPTIN_SCHEMA)?;
133 match &self.optin_id {
134 Some(id) => validate_id(id, "optin_id")?,
135 None if self.active
136 || !self.vaults.is_empty()
137 || !self.networks.is_empty()
138 || !self.topics.is_empty() =>
139 {
140 return Err(ValidationError::new(
141 "an engine without opt-in is inactive with empty scope",
142 ));
143 }
144 None => {}
145 }
146 if self.vaults.len() > MAX_OPTIN_VAULTS || self.networks.len() > MAX_OPTIN_NETWORKS {
147 return Err(ValidationError::new(format!(
148 "at most {MAX_OPTIN_VAULTS} vaults and {MAX_OPTIN_NETWORKS} networks"
149 )));
150 }
151 for (i, vault) in self.vaults.iter().enumerate() {
152 super::validate_vault_id(vault)?;
153 if i > 0 && self.vaults[i - 1] >= *vault {
154 return Err(ValidationError::new(
155 "vaults must be sorted without repeats",
156 ));
157 }
158 }
159 if self.topics.len() > MAX_OPTIN_TOPICS
160 || !self.topics.iter().all(|t| topic_ok(t))
161 || self.topics.windows(2).any(|pair| pair[0] >= pair[1])
162 {
163 return Err(ValidationError::new(
164 "up to 5 topics of 2-32 [a-z0-9-], sorted without repeats",
165 ));
166 }
167 if self
168 .daily_budget
169 .tasks
170 .is_some_and(|t| !(1..=MAX_DAILY_TASKS).contains(&t))
171 || self.daily_budget.tokens == Some(0)
172 {
173 return Err(ValidationError::new(
174 "daily_budget is 1-100 tasks or none, and a non-zero token limit",
175 ));
176 }
177 if !day_ok(&self.used_today.day) {
178 return Err(ValidationError::new("used_today.day is YYYY-MM-DD"));
179 }
180 if self.active == self.stopped_at.is_some() && self.optin_id.is_some() {
181 return Err(ValidationError::new(
182 "stopped_at is set exactly while the opt-in is inactive",
183 ));
184 }
185 Ok(())
186 }
187}
188
189#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
191#[serde(deny_unknown_fields)]
192pub struct EngineOptinStopV1 {
193 pub schema: String,
194 pub active: bool,
196}
197
198impl EngineOptinStopV1 {
199 pub fn validate(&self) -> Result<(), ValidationError> {
200 require_schema(&self.schema, ENGINE_OPTIN_STOP_SCHEMA)?;
201 if self.active {
202 return Err(ValidationError::new("an engine can only stop its opt-in"));
203 }
204 Ok(())
205 }
206}