1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
5#[serde(rename_all = "snake_case")]
6pub enum PolicyAction {
7 Deny,
8}
9
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(tag = "type", rename_all = "snake_case")]
13pub enum PolicyRule {
14 AllowedChains { chain_ids: Vec<String> },
16
17 ExpiresAt { timestamp: String },
19
20 AllowedTypedDataContracts { contracts: Vec<String> },
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct Policy {
28 pub id: String,
29 pub name: String,
30 pub version: u32,
31 pub created_at: String,
32 pub rules: Vec<PolicyRule>,
33 #[serde(skip_serializing_if = "Option::is_none")]
35 pub executable: Option<String>,
36 #[serde(skip_serializing_if = "Option::is_none")]
38 pub config: Option<serde_json::Value>,
39 pub action: PolicyAction,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct PolicyContext {
45 pub chain_id: String,
46 pub wallet_id: String,
47 pub api_key_id: String,
48 pub transaction: TransactionContext,
49 pub spending: SpendingContext,
50 pub timestamp: String,
51 #[serde(skip_serializing_if = "Option::is_none")]
53 pub typed_data: Option<TypedDataContext>,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct TransactionContext {
59 #[serde(skip_serializing_if = "Option::is_none")]
61 pub to: Option<String>,
62 #[serde(skip_serializing_if = "Option::is_none")]
64 pub value: Option<String>,
65 pub raw_hex: String,
68 #[serde(skip_serializing_if = "Option::is_none")]
70 pub data: Option<String>,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct SpendingContext {
76 pub daily_total: String,
78 pub date: String,
80}
81
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84pub struct TypedDataContext {
85 #[serde(skip_serializing_if = "Option::is_none")]
87 pub verifying_contract: Option<String>,
88 #[serde(skip_serializing_if = "Option::is_none")]
90 pub domain_chain_id: Option<u64>,
91 pub primary_type: String,
93 #[serde(skip_serializing_if = "Option::is_none")]
95 pub domain_name: Option<String>,
96 #[serde(skip_serializing_if = "Option::is_none")]
98 pub domain_version: Option<String>,
99 pub raw_json: String,
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct PolicyResult {
106 pub allow: bool,
107 #[serde(skip_serializing_if = "Option::is_none")]
108 pub reason: Option<String>,
109 #[serde(skip_serializing_if = "Option::is_none")]
111 pub policy_id: Option<String>,
112}
113
114impl PolicyResult {
115 pub fn allowed() -> Self {
116 Self {
117 allow: true,
118 reason: None,
119 policy_id: None,
120 }
121 }
122
123 pub fn denied(policy_id: impl Into<String>, reason: impl Into<String>) -> Self {
124 Self {
125 allow: false,
126 reason: Some(reason.into()),
127 policy_id: Some(policy_id.into()),
128 }
129 }
130}
131
132#[cfg(test)]
133mod tests {
134 use super::*;
135
136 #[test]
137 fn test_policy_rule_serde_allowed_chains() {
138 let rule = PolicyRule::AllowedChains {
139 chain_ids: vec!["eip155:8453".into(), "eip155:84532".into()],
140 };
141 let json = serde_json::to_value(&rule).unwrap();
142 assert_eq!(json["type"], "allowed_chains");
143 assert_eq!(json["chain_ids"][0], "eip155:8453");
144
145 let deserialized: PolicyRule = serde_json::from_value(json).unwrap();
146 assert_eq!(deserialized, rule);
147 }
148
149 #[test]
150 fn test_policy_rule_serde_expires_at() {
151 let rule = PolicyRule::ExpiresAt {
152 timestamp: "2026-04-01T00:00:00Z".to_string(),
153 };
154 let json = serde_json::to_value(&rule).unwrap();
155 assert_eq!(json["type"], "expires_at");
156 assert_eq!(json["timestamp"], "2026-04-01T00:00:00Z");
157
158 let deserialized: PolicyRule = serde_json::from_value(json).unwrap();
159 assert_eq!(deserialized, rule);
160 }
161
162 #[test]
163 fn test_policy_serde_roundtrip() {
164 let policy = Policy {
165 id: "base-agent-limits".into(),
166 name: "Base Agent Safety Limits".into(),
167 version: 1,
168 created_at: "2026-03-22T10:00:00Z".into(),
169 rules: vec![
170 PolicyRule::AllowedChains {
171 chain_ids: vec!["eip155:8453".into()],
172 },
173 PolicyRule::ExpiresAt {
174 timestamp: "2026-12-31T23:59:59Z".into(),
175 },
176 ],
177 executable: None,
178 config: None,
179 action: PolicyAction::Deny,
180 };
181
182 let json = serde_json::to_string(&policy).unwrap();
183 let deserialized: Policy = serde_json::from_str(&json).unwrap();
184 assert_eq!(deserialized.id, "base-agent-limits");
185 assert_eq!(deserialized.rules.len(), 2);
186 assert!(deserialized.executable.is_none());
187 }
188
189 #[test]
190 fn test_policy_with_executable() {
191 let json = r#"{
192 "id": "sim-policy",
193 "name": "Simulation Policy",
194 "version": 1,
195 "created_at": "2026-03-22T10:00:00Z",
196 "rules": [],
197 "executable": "/usr/local/bin/simulate-tx",
198 "config": {"rpc": "https://mainnet.base.org"},
199 "action": "deny"
200 }"#;
201 let policy: Policy = serde_json::from_str(json).unwrap();
202 assert_eq!(policy.executable.unwrap(), "/usr/local/bin/simulate-tx");
203 assert!(policy.config.is_some());
204 }
205
206 #[test]
207 fn test_policy_context_serde() {
208 let ctx = PolicyContext {
209 chain_id: "eip155:8453".into(),
210 wallet_id: "3198bc9c-6672-5ab3-d995-4942343ae5b6".into(),
211 api_key_id: "7a2f1b3c-4d5e-6f7a-8b9c-0d1e2f3a4b5c".into(),
212 transaction: TransactionContext {
213 to: Some("0x742d35Cc6634C0532925a3b844Bc9e7595f2bD0C".into()),
214 value: Some("100000000000000000".into()),
215 raw_hex: "0x02f8...".into(),
216 data: None,
217 },
218 spending: SpendingContext {
219 daily_total: "50000000000000000".into(),
220 date: "2026-03-22".into(),
221 },
222 timestamp: "2026-03-22T10:35:22Z".into(),
223 typed_data: None,
224 };
225
226 let json = serde_json::to_string(&ctx).unwrap();
227 let deserialized: PolicyContext = serde_json::from_str(&json).unwrap();
228 assert_eq!(deserialized.chain_id, "eip155:8453");
229 assert_eq!(
230 deserialized.transaction.to.unwrap(),
231 "0x742d35Cc6634C0532925a3b844Bc9e7595f2bD0C"
232 );
233 assert!(!json.contains("\"data\""));
235 }
236
237 #[test]
238 fn test_policy_result_allowed() {
239 let result = PolicyResult::allowed();
240 assert!(result.allow);
241 assert!(result.reason.is_none());
242 assert!(result.policy_id.is_none());
243
244 let json = serde_json::to_value(&result).unwrap();
245 assert_eq!(json["allow"], true);
246 assert!(!json.as_object().unwrap().contains_key("reason"));
247 }
248
249 #[test]
250 fn test_policy_result_denied() {
251 let result = PolicyResult::denied(
252 "spending-limit",
253 "Daily spending limit exceeded: 0.95 / 1.0 ETH",
254 );
255 assert!(!result.allow);
256 assert_eq!(
257 result.reason.as_deref(),
258 Some("Daily spending limit exceeded: 0.95 / 1.0 ETH")
259 );
260 assert_eq!(result.policy_id.as_deref(), Some("spending-limit"));
261 }
262
263 #[test]
264 fn test_policy_action_serde() {
265 let action = PolicyAction::Deny;
266 let json = serde_json::to_value(&action).unwrap();
267 assert_eq!(json, "deny");
268
269 let deserialized: PolicyAction = serde_json::from_value(json).unwrap();
270 assert_eq!(deserialized, PolicyAction::Deny);
271 }
272
273 #[test]
274 fn test_typed_data_context_serde() {
275 let ctx = TypedDataContext {
276 verifying_contract: Some("0x000000000022D473030F116dDEE9F6B43aC78BA3".into()),
277 domain_chain_id: Some(8453),
278 primary_type: "PermitSingle".into(),
279 domain_name: Some("Permit2".into()),
280 domain_version: Some("1".into()),
281 raw_json: r#"{"types":{},"primaryType":"PermitSingle","domain":{},"message":{}}"#
282 .into(),
283 };
284
285 let json = serde_json::to_string(&ctx).unwrap();
286 let deserialized: TypedDataContext = serde_json::from_str(&json).unwrap();
287 assert_eq!(deserialized.primary_type, "PermitSingle");
288 assert_eq!(
289 deserialized.verifying_contract.as_deref(),
290 Some("0x000000000022D473030F116dDEE9F6B43aC78BA3")
291 );
292 assert_eq!(deserialized.domain_chain_id, Some(8453));
293 }
294
295 #[test]
296 fn test_typed_data_context_optional_fields_omitted() {
297 let ctx = TypedDataContext {
298 verifying_contract: None,
299 domain_chain_id: None,
300 primary_type: "Mail".into(),
301 domain_name: None,
302 domain_version: None,
303 raw_json: "{}".into(),
304 };
305
306 let json = serde_json::to_string(&ctx).unwrap();
307 assert!(!json.contains("verifying_contract"));
308 assert!(!json.contains("domain_chain_id"));
309 assert!(!json.contains("domain_name"));
310 assert!(!json.contains("domain_version"));
311 }
312
313 #[test]
314 fn test_policy_rule_serde_allowed_typed_data_contracts() {
315 let rule = PolicyRule::AllowedTypedDataContracts {
316 contracts: vec!["0x000000000022D473030F116dDEE9F6B43aC78BA3".into()],
317 };
318 let json = serde_json::to_value(&rule).unwrap();
319 assert_eq!(json["type"], "allowed_typed_data_contracts");
320 assert_eq!(
321 json["contracts"][0],
322 "0x000000000022D473030F116dDEE9F6B43aC78BA3"
323 );
324
325 let deserialized: PolicyRule = serde_json::from_value(json).unwrap();
326 assert_eq!(deserialized, rule);
327 }
328
329 #[test]
330 fn test_policy_context_typed_data_none_omitted() {
331 let ctx = PolicyContext {
332 chain_id: "eip155:8453".into(),
333 wallet_id: "w".into(),
334 api_key_id: "k".into(),
335 transaction: TransactionContext {
336 to: None,
337 value: None,
338 raw_hex: "0x00".into(),
339 data: None,
340 },
341 spending: SpendingContext {
342 daily_total: "0".into(),
343 date: "2026-03-30".into(),
344 },
345 timestamp: "2026-03-30T12:00:00Z".into(),
346 typed_data: None,
347 };
348
349 let json = serde_json::to_string(&ctx).unwrap();
350 assert!(!json.contains("typed_data"));
351 }
352}