1use super::types::{AppDataDoc, CowHook, Metadata, OrderInteractionHooks, PartnerFee};
22
23#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum ValidationError {
44 InvalidAppCode(String),
46 InvalidVersion(String),
48 InvalidHookTarget {
50 hook: String,
52 reason: String,
54 },
55 InvalidHookGasLimit {
57 gas_limit: String,
59 },
60 PartnerFeeBpsTooHigh(u32),
63 UnknownOrderClass(String),
65 InvalidReplacedOrderUid(String),
67 SchemaViolation {
71 path: String,
73 message: String,
75 },
76}
77
78impl std::fmt::Display for ValidationError {
79 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80 match self {
81 Self::InvalidAppCode(s) => write!(f, "invalid appCode: {s}"),
82 Self::InvalidVersion(s) => write!(f, "invalid version: {s}"),
83 Self::InvalidHookTarget { hook, reason } => {
84 write!(f, "invalid hook target '{hook}': {reason}")
85 }
86 Self::InvalidHookGasLimit { gas_limit } => {
87 write!(f, "invalid hook gasLimit '{gas_limit}': not a valid u64")
88 }
89 Self::PartnerFeeBpsTooHigh(bps) => {
90 write!(f, "partnerFee bps {bps} exceeds 10 000 (100 %)")
91 }
92 Self::UnknownOrderClass(s) => write!(f, "unknown orderClass '{s}'"),
93 Self::InvalidReplacedOrderUid(s) => {
94 write!(f, "invalid replacedOrder uid '{s}': expected 0x + 112 hex chars")
95 }
96 Self::SchemaViolation { path, message } => {
97 if path.is_empty() {
98 write!(f, "schema: {message}")
99 } else {
100 write!(f, "schema: {message} at {path}")
101 }
102 }
103 }
104 }
105}
106
107pub(super) fn validate_constraints(doc: &AppDataDoc, errors: &mut Vec<ValidationError>) {
123 validate_app_code(doc.app_code.as_deref(), errors);
124 validate_metadata(&doc.metadata, errors);
125}
126
127fn validate_app_code(app_code: Option<&str>, errors: &mut Vec<ValidationError>) {
139 let Some(code) = app_code else { return };
140 if code.is_empty() {
141 errors.push(ValidationError::InvalidAppCode("appCode must not be empty".to_owned()));
142 } else if code.len() > 50 {
143 errors.push(ValidationError::InvalidAppCode(format!(
144 "appCode '{}' exceeds 50 characters (got {})",
145 code,
146 code.len()
147 )));
148 }
149}
150
151fn validate_metadata(meta: &Metadata, errors: &mut Vec<ValidationError>) {
161 if let Some(hooks) = &meta.hooks {
162 validate_hooks(hooks, errors);
163 }
164 if let Some(fee) = &meta.partner_fee {
165 validate_partner_fee(fee, errors);
166 }
167 if let Some(oc) = &meta.order_class {
168 let known = ["market", "limit", "liquidity", "twap"];
172 let s = oc.order_class.as_str();
173 if !known.contains(&s) {
174 errors.push(ValidationError::UnknownOrderClass(s.to_owned()));
175 }
176 }
177 if let Some(ro) = &meta.replaced_order {
178 validate_replaced_order_uid(&ro.uid, errors);
179 }
180}
181
182fn validate_hooks(hooks: &OrderInteractionHooks, errors: &mut Vec<ValidationError>) {
192 if let Some(pre) = &hooks.pre {
193 for hook in pre {
194 validate_single_hook(hook, errors);
195 }
196 }
197 if let Some(post) = &hooks.post {
198 for hook in post {
199 validate_single_hook(hook, errors);
200 }
201 }
202}
203
204fn validate_single_hook(hook: &CowHook, errors: &mut Vec<ValidationError>) {
217 if !is_eth_address(&hook.target) {
218 errors.push(ValidationError::InvalidHookTarget {
219 hook: hook.target.clone(),
220 reason: "expected 0x-prefixed 20-byte hex address".to_owned(),
221 });
222 }
223 if hook.gas_limit.parse::<u64>().is_err() {
224 errors.push(ValidationError::InvalidHookGasLimit { gas_limit: hook.gas_limit.clone() });
225 }
226}
227
228fn validate_partner_fee(fee: &PartnerFee, errors: &mut Vec<ValidationError>) {
240 for entry in fee.entries() {
241 for bps in
242 [entry.volume_bps, entry.surplus_bps, entry.price_improvement_bps].into_iter().flatten()
243 {
244 if bps > 10_000 {
245 errors.push(ValidationError::PartnerFeeBpsTooHigh(bps));
246 }
247 }
248 }
249}
250
251fn validate_replaced_order_uid(uid: &str, errors: &mut Vec<ValidationError>) {
265 let valid = uid.len() == 114 &&
267 uid.starts_with("0x") &&
268 uid[2..].chars().all(|c| c.is_ascii_hexdigit());
269 if !valid {
270 errors.push(ValidationError::InvalidReplacedOrderUid(uid.to_owned()));
271 }
272}
273
274fn is_eth_address(s: &str) -> bool {
289 s.len() == 42 && s.starts_with("0x") && s[2..].chars().all(|c| c.is_ascii_hexdigit())
290}
291
292#[cfg(test)]
293mod tests {
294 use super::*;
295 use crate::app_data::types::{OrderClass, OrderClassKind, PartnerFeeEntry, ReplacedOrder};
296
297 #[test]
298 fn validate_app_code_empty() {
299 let mut errors = Vec::new();
300 validate_app_code(Some(""), &mut errors);
301 assert!(!errors.is_empty());
302 assert!(matches!(errors[0], ValidationError::InvalidAppCode(_)));
303 }
304
305 #[test]
306 fn validate_app_code_too_long() {
307 let mut errors = Vec::new();
308 let long = "A".repeat(51);
309 validate_app_code(Some(&long), &mut errors);
310 assert!(!errors.is_empty());
311 assert!(matches!(errors[0], ValidationError::InvalidAppCode(_)));
312 }
313
314 #[test]
315 fn validate_app_code_none_is_ok() {
316 let mut errors = Vec::new();
317 validate_app_code(None, &mut errors);
318 assert!(errors.is_empty());
319 }
320
321 #[test]
322 fn validate_app_code_valid() {
323 let mut errors = Vec::new();
324 validate_app_code(Some("MyApp"), &mut errors);
325 assert!(errors.is_empty());
326 }
327
328 #[test]
329 fn validate_hook_invalid_target() {
330 let mut errors = Vec::new();
331 let hook = CowHook {
332 target: "not-an-address".to_owned(),
333 call_data: "0x".to_owned(),
334 gas_limit: "100000".to_owned(),
335 dapp_id: None,
336 };
337 validate_single_hook(&hook, &mut errors);
338 assert!(errors.iter().any(|e| matches!(e, ValidationError::InvalidHookTarget { .. })));
339 }
340
341 #[test]
342 fn validate_hook_invalid_gas_limit() {
343 let mut errors = Vec::new();
344 let hook = CowHook {
345 target: "0x1111111111111111111111111111111111111111".to_owned(),
346 call_data: "0x".to_owned(),
347 gas_limit: "not-a-number".to_owned(),
348 dapp_id: None,
349 };
350 validate_single_hook(&hook, &mut errors);
351 assert!(errors.iter().any(|e| matches!(e, ValidationError::InvalidHookGasLimit { .. })));
352 }
353
354 #[test]
355 fn validate_hook_valid() {
356 let mut errors = Vec::new();
357 let hook = CowHook {
358 target: "0x1111111111111111111111111111111111111111".to_owned(),
359 call_data: "0x".to_owned(),
360 gas_limit: "100000".to_owned(),
361 dapp_id: None,
362 };
363 validate_single_hook(&hook, &mut errors);
364 assert!(errors.is_empty());
365 }
366
367 #[test]
368 fn validate_partner_fee_bps_too_high() {
369 let mut errors = Vec::new();
370 let fee = PartnerFee::Single(PartnerFeeEntry {
371 recipient: "0x1111111111111111111111111111111111111111".to_owned(),
372 volume_bps: Some(10_001),
373 surplus_bps: None,
374 price_improvement_bps: None,
375 max_volume_bps: None,
376 });
377 validate_partner_fee(&fee, &mut errors);
378 assert!(errors.iter().any(|e| matches!(e, ValidationError::PartnerFeeBpsTooHigh(10_001))));
379 }
380
381 #[test]
382 fn validate_partner_fee_valid() {
383 let mut errors = Vec::new();
384 let fee = PartnerFee::Single(PartnerFeeEntry {
385 recipient: "0x1111111111111111111111111111111111111111".to_owned(),
386 volume_bps: Some(500),
387 surplus_bps: None,
388 price_improvement_bps: None,
389 max_volume_bps: None,
390 });
391 validate_partner_fee(&fee, &mut errors);
392 assert!(errors.is_empty());
393 }
394
395 #[test]
396 fn validate_replaced_order_uid_valid() {
397 let mut errors = Vec::new();
398 let uid = format!("0x{}", "ab".repeat(56));
399 validate_replaced_order_uid(&uid, &mut errors);
400 assert!(errors.is_empty());
401 }
402
403 #[test]
404 fn validate_replaced_order_uid_invalid() {
405 let mut errors = Vec::new();
406 validate_replaced_order_uid("0xshort", &mut errors);
407 assert!(errors.iter().any(|e| matches!(e, ValidationError::InvalidReplacedOrderUid(_))));
408 }
409
410 #[test]
411 fn validation_error_display_all_variants() {
412 let err = ValidationError::InvalidAppCode("test".into());
413 assert!(err.to_string().contains("test"));
414
415 let err = ValidationError::InvalidVersion("bad".into());
416 assert!(err.to_string().contains("bad"));
417
418 let err = ValidationError::InvalidHookTarget { hook: "foo".into(), reason: "bar".into() };
419 assert!(err.to_string().contains("foo"));
420
421 let err = ValidationError::InvalidHookGasLimit { gas_limit: "xyz".into() };
422 assert!(err.to_string().contains("xyz"));
423
424 let err = ValidationError::PartnerFeeBpsTooHigh(20_000);
425 assert!(err.to_string().contains("20000"));
426
427 let err = ValidationError::UnknownOrderClass("unknown".into());
428 assert!(err.to_string().contains("unknown"));
429
430 let err = ValidationError::InvalidReplacedOrderUid("0xshort".into());
431 assert!(err.to_string().contains("0xshort"));
432
433 let err = ValidationError::SchemaViolation { path: "/foo".into(), message: "bad".into() };
434 assert!(err.to_string().contains("/foo"));
435
436 let err = ValidationError::SchemaViolation { path: String::new(), message: "root".into() };
437 assert!(err.to_string().contains("root"));
438 assert!(!err.to_string().contains(" at "));
439 }
440
441 #[test]
442 fn validate_metadata_with_order_class() {
443 let mut errors = Vec::new();
444 let meta = Metadata {
445 order_class: Some(OrderClass { order_class: OrderClassKind::Market }),
446 ..Metadata::default()
447 };
448 validate_metadata(&meta, &mut errors);
449 assert!(errors.is_empty());
450 }
451
452 #[test]
453 fn validate_metadata_with_replaced_order() {
454 let mut errors = Vec::new();
455 let uid = format!("0x{}", "ab".repeat(56));
456 let meta = Metadata { replaced_order: Some(ReplacedOrder { uid }), ..Metadata::default() };
457 validate_metadata(&meta, &mut errors);
458 assert!(errors.is_empty());
459 }
460}