Skip to main content

cow_rs/app_data/
validation.rs

1//! Extended constraint validation for [`AppDataDoc`] — strict schema rules.
2//!
3//! This module provides the [`ValidationError`] type that describes specific
4//! constraint violations, and the `pub(super)` helper
5//! [`validate_constraints`] used by
6//! [`validate_app_data_doc`](super::ipfs::validate_app_data_doc) to check
7//! every field of an [`AppDataDoc`].
8//!
9//! # Validation rules
10//!
11//! | Field | Rule |
12//! |---|---|
13//! | `appCode` | Non-empty, ≤ 50 characters |
14//! | `version` | Valid semver (`x.y.z`) |
15//! | Hook `target` | `0x` + 40 hex chars |
16//! | Hook `gasLimit` | Parseable as decimal `u64` |
17//! | `partnerFee` bps | Each ≤ 10 000 |
18//! | `orderClass` | One of `market`, `limit`, `liquidity`, `twap` |
19//! | `replacedOrder.uid` | `0x` + 112 hex chars (56 bytes) |
20
21use super::types::{AppDataDoc, CowHook, Metadata, OrderInteractionHooks, PartnerFee};
22
23// ── ValidationError ────────────────────────────────────────────────────────
24
25/// A specific constraint violation found when validating an [`AppDataDoc`].
26///
27/// Every variant carries enough context to display a useful diagnostic
28/// message via its [`Display`](std::fmt::Display) implementation. Variants
29/// are returned inside
30/// [`ValidationResult::typed_errors`](super::ipfs::ValidationResult::typed_errors)
31/// for programmatic inspection.
32///
33/// # Example
34///
35/// ```
36/// use cow_rs::app_data::{AppDataDoc, ValidationError, validate_app_data_doc};
37///
38/// let doc = AppDataDoc::new(""); // empty appCode triggers InvalidAppCode
39/// let result = validate_app_data_doc(&doc);
40/// assert!(result.errors_ref().iter().any(|e| matches!(e, ValidationError::InvalidAppCode(_))));
41/// ```
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum ValidationError {
44    /// `appCode` is empty or exceeds 50 characters.
45    InvalidAppCode(String),
46    /// `metadata.version` is not a valid semver string (`x.y.z`).
47    InvalidVersion(String),
48    /// A hook `target` is not a valid Ethereum address (`0x` + 40 hex chars).
49    InvalidHookTarget {
50        /// The invalid target string.
51        hook: String,
52        /// Human-readable reason.
53        reason: String,
54    },
55    /// A hook `gasLimit` is not parseable as a decimal `u64`.
56    InvalidHookGasLimit {
57        /// The invalid gas-limit string.
58        gas_limit: String,
59    },
60    /// `partnerFee` entry `volumeBps` / `surplusBps` / `priceImprovementBps` exceeds 10 000 (100
61    /// %).
62    PartnerFeeBpsTooHigh(u32),
63    /// `orderClass.orderClass` contains an unrecognised variant.
64    UnknownOrderClass(String),
65    /// A `replacedOrder` UID is not 56 bytes (i.e. not `"0x"` + 112 hex chars).
66    InvalidReplacedOrderUid(String),
67    /// A structural violation reported by the bundled JSON Schema validator
68    /// in [`super::schema`] — e.g. a missing required field, an unknown
69    /// property, or a value that does not match a regex / enum constraint.
70    SchemaViolation {
71        /// JSON pointer into the instance that triggered the violation.
72        path: String,
73        /// Human-readable description of the violation.
74        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
107// ── Public entry-point ─────────────────────────────────────────────────────
108
109/// Validate all constraint rules of `doc` and push any violations into
110/// `errors`.
111///
112/// Called by
113/// [`validate_app_data_doc`](super::ipfs::validate_app_data_doc) after the
114/// basic version check. Walks the document's `app_code` and `metadata`
115/// fields, delegating to per-field validators that append
116/// [`ValidationError`] entries for every violated rule.
117///
118/// # Parameters
119///
120/// * `doc` — the [`AppDataDoc`] to validate.
121/// * `errors` — mutable list to which violations are appended.
122pub(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
127// ── Private helpers ────────────────────────────────────────────────────────
128
129/// Validate the `appCode` field.
130///
131/// Allowed: non-empty string of at most 50 characters. `None` values are
132/// valid (the field is optional in the schema).
133///
134/// # Parameters
135///
136/// * `app_code` — the `appCode` value to validate (`None` = not set).
137/// * `errors` — mutable list to which violations are appended.
138fn 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
151/// Validate the `metadata` block.
152///
153/// Delegates to per-field validators for hooks, partner fees, order class,
154/// and replaced-order UID. Fields that are `None` are silently skipped.
155///
156/// # Parameters
157///
158/// * `meta` — the [`Metadata`] block to validate.
159/// * `errors` — mutable list to which violations are appended.
160fn 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        // OrderClassKind is an enum — all known variants are valid; only a
169        // round-trip through serde could produce an unknown variant, but we
170        // still expose the check through the `as_str` method for completeness.
171        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
182/// Validate all hooks inside an [`OrderInteractionHooks`] block.
183///
184/// Iterates over both `pre` and `post` hook lists and validates each
185/// individual [`CowHook`] via [`validate_single_hook`].
186///
187/// # Parameters
188///
189/// * `hooks` — the hooks block to validate.
190/// * `errors` — mutable list to which violations are appended.
191fn 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
204/// Validate a single [`CowHook`].
205///
206/// Two rules are checked:
207///
208/// 1. `target` must be a valid Ethereum address (`"0x"` + 40 hex chars, case-insensitive). Produces
209///    [`ValidationError::InvalidHookTarget`].
210/// 2. `gas_limit` must parse as a decimal `u64`. Produces [`ValidationError::InvalidHookGasLimit`].
211///
212/// # Parameters
213///
214/// * `hook` — the [`CowHook`] to validate.
215/// * `errors` — mutable list to which violations are appended.
216fn 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
228/// Validate every basis-point value in a [`PartnerFee`].
229///
230/// Iterates all entries (single or multiple) and checks that each of
231/// `volume_bps`, `surplus_bps`, and `price_improvement_bps` is ≤ 10 000
232/// (= 100 %) when present. Produces [`ValidationError::PartnerFeeBpsTooHigh`]
233/// for every value that exceeds the cap.
234///
235/// # Parameters
236///
237/// * `fee` — the [`PartnerFee`] to validate.
238/// * `errors` — mutable list to which violations are appended.
239fn 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
251/// Validate a `replacedOrder.uid` string.
252///
253/// Expected format: `"0x"` followed by exactly 112 hex characters
254/// (= 56 bytes = the `CoW` Protocol order-UID format: 32 bytes order hash
255/// + 20 bytes owner address + 4 bytes valid-to timestamp).
256///
257/// Hex digits are accepted in any case (the protocol normalises to
258/// lowercase).
259///
260/// # Parameters
261///
262/// * `uid` — the order UID string to validate.
263/// * `errors` — mutable list to which violations are appended.
264fn validate_replaced_order_uid(uid: &str, errors: &mut Vec<ValidationError>) {
265    // 2 (prefix) + 112 (hex) = 114
266    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
274/// Return `true` when `s` looks like a valid Ethereum address.
275///
276/// Accepts `"0x"` + exactly 40 ASCII hex characters (case-insensitive).
277/// Does **not** enforce `EIP-55` mixed-case checksum because that would
278/// require a `keccak256` call and is not part of the `CoW` Protocol
279/// app-data schema validation rules.
280///
281/// # Parameters
282///
283/// * `s` — the string to check.
284///
285/// # Returns
286///
287/// `true` if `s` matches the `0x[0-9a-fA-F]{40}` pattern.
288fn 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}