use super::types::{AppDataDoc, CowHook, Metadata, OrderInteractionHooks, PartnerFee};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ValidationError {
InvalidAppCode(String),
InvalidVersion(String),
InvalidHookTarget {
hook: String,
reason: String,
},
InvalidHookGasLimit {
gas_limit: String,
},
PartnerFeeBpsTooHigh(u32),
UnknownOrderClass(String),
InvalidReplacedOrderUid(String),
SchemaViolation {
path: String,
message: String,
},
}
impl std::fmt::Display for ValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidAppCode(s) => write!(f, "invalid appCode: {s}"),
Self::InvalidVersion(s) => write!(f, "invalid version: {s}"),
Self::InvalidHookTarget { hook, reason } => {
write!(f, "invalid hook target '{hook}': {reason}")
}
Self::InvalidHookGasLimit { gas_limit } => {
write!(f, "invalid hook gasLimit '{gas_limit}': not a valid u64")
}
Self::PartnerFeeBpsTooHigh(bps) => {
write!(f, "partnerFee bps {bps} exceeds 10 000 (100 %)")
}
Self::UnknownOrderClass(s) => write!(f, "unknown orderClass '{s}'"),
Self::InvalidReplacedOrderUid(s) => {
write!(f, "invalid replacedOrder uid '{s}': expected 0x + 112 hex chars")
}
Self::SchemaViolation { path, message } => {
if path.is_empty() {
write!(f, "schema: {message}")
} else {
write!(f, "schema: {message} at {path}")
}
}
}
}
}
pub(super) fn validate_constraints(doc: &AppDataDoc, errors: &mut Vec<ValidationError>) {
validate_app_code(doc.app_code.as_deref(), errors);
validate_metadata(&doc.metadata, errors);
}
fn validate_app_code(app_code: Option<&str>, errors: &mut Vec<ValidationError>) {
let Some(code) = app_code else { return };
if code.is_empty() {
errors.push(ValidationError::InvalidAppCode("appCode must not be empty".to_owned()));
} else if code.len() > 50 {
errors.push(ValidationError::InvalidAppCode(format!(
"appCode '{}' exceeds 50 characters (got {})",
code,
code.len()
)));
}
}
fn validate_metadata(meta: &Metadata, errors: &mut Vec<ValidationError>) {
if let Some(hooks) = &meta.hooks {
validate_hooks(hooks, errors);
}
if let Some(fee) = &meta.partner_fee {
validate_partner_fee(fee, errors);
}
if let Some(oc) = &meta.order_class {
let known = ["market", "limit", "liquidity", "twap"];
let s = oc.order_class.as_str();
if !known.contains(&s) {
errors.push(ValidationError::UnknownOrderClass(s.to_owned()));
}
}
if let Some(ro) = &meta.replaced_order {
validate_replaced_order_uid(&ro.uid, errors);
}
}
fn validate_hooks(hooks: &OrderInteractionHooks, errors: &mut Vec<ValidationError>) {
if let Some(pre) = &hooks.pre {
for hook in pre {
validate_single_hook(hook, errors);
}
}
if let Some(post) = &hooks.post {
for hook in post {
validate_single_hook(hook, errors);
}
}
}
fn validate_single_hook(hook: &CowHook, errors: &mut Vec<ValidationError>) {
if !is_eth_address(&hook.target) {
errors.push(ValidationError::InvalidHookTarget {
hook: hook.target.clone(),
reason: "expected 0x-prefixed 20-byte hex address".to_owned(),
});
}
if hook.gas_limit.parse::<u64>().is_err() {
errors.push(ValidationError::InvalidHookGasLimit { gas_limit: hook.gas_limit.clone() });
}
}
fn validate_partner_fee(fee: &PartnerFee, errors: &mut Vec<ValidationError>) {
for entry in fee.entries() {
for bps in
[entry.volume_bps, entry.surplus_bps, entry.price_improvement_bps].into_iter().flatten()
{
if bps > 10_000 {
errors.push(ValidationError::PartnerFeeBpsTooHigh(bps));
}
}
}
}
fn validate_replaced_order_uid(uid: &str, errors: &mut Vec<ValidationError>) {
let valid = uid.len() == 114 &&
uid.starts_with("0x") &&
uid[2..].chars().all(|c| c.is_ascii_hexdigit());
if !valid {
errors.push(ValidationError::InvalidReplacedOrderUid(uid.to_owned()));
}
}
fn is_eth_address(s: &str) -> bool {
s.len() == 42 && s.starts_with("0x") && s[2..].chars().all(|c| c.is_ascii_hexdigit())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app_data::types::{OrderClass, OrderClassKind, PartnerFeeEntry, ReplacedOrder};
#[test]
fn validate_app_code_empty() {
let mut errors = Vec::new();
validate_app_code(Some(""), &mut errors);
assert!(!errors.is_empty());
assert!(matches!(errors[0], ValidationError::InvalidAppCode(_)));
}
#[test]
fn validate_app_code_too_long() {
let mut errors = Vec::new();
let long = "A".repeat(51);
validate_app_code(Some(&long), &mut errors);
assert!(!errors.is_empty());
assert!(matches!(errors[0], ValidationError::InvalidAppCode(_)));
}
#[test]
fn validate_app_code_none_is_ok() {
let mut errors = Vec::new();
validate_app_code(None, &mut errors);
assert!(errors.is_empty());
}
#[test]
fn validate_app_code_valid() {
let mut errors = Vec::new();
validate_app_code(Some("MyApp"), &mut errors);
assert!(errors.is_empty());
}
#[test]
fn validate_hook_invalid_target() {
let mut errors = Vec::new();
let hook = CowHook {
target: "not-an-address".to_owned(),
call_data: "0x".to_owned(),
gas_limit: "100000".to_owned(),
dapp_id: None,
};
validate_single_hook(&hook, &mut errors);
assert!(errors.iter().any(|e| matches!(e, ValidationError::InvalidHookTarget { .. })));
}
#[test]
fn validate_hook_invalid_gas_limit() {
let mut errors = Vec::new();
let hook = CowHook {
target: "0x1111111111111111111111111111111111111111".to_owned(),
call_data: "0x".to_owned(),
gas_limit: "not-a-number".to_owned(),
dapp_id: None,
};
validate_single_hook(&hook, &mut errors);
assert!(errors.iter().any(|e| matches!(e, ValidationError::InvalidHookGasLimit { .. })));
}
#[test]
fn validate_hook_valid() {
let mut errors = Vec::new();
let hook = CowHook {
target: "0x1111111111111111111111111111111111111111".to_owned(),
call_data: "0x".to_owned(),
gas_limit: "100000".to_owned(),
dapp_id: None,
};
validate_single_hook(&hook, &mut errors);
assert!(errors.is_empty());
}
#[test]
fn validate_partner_fee_bps_too_high() {
let mut errors = Vec::new();
let fee = PartnerFee::Single(PartnerFeeEntry {
recipient: "0x1111111111111111111111111111111111111111".to_owned(),
volume_bps: Some(10_001),
surplus_bps: None,
price_improvement_bps: None,
max_volume_bps: None,
});
validate_partner_fee(&fee, &mut errors);
assert!(errors.iter().any(|e| matches!(e, ValidationError::PartnerFeeBpsTooHigh(10_001))));
}
#[test]
fn validate_partner_fee_valid() {
let mut errors = Vec::new();
let fee = PartnerFee::Single(PartnerFeeEntry {
recipient: "0x1111111111111111111111111111111111111111".to_owned(),
volume_bps: Some(500),
surplus_bps: None,
price_improvement_bps: None,
max_volume_bps: None,
});
validate_partner_fee(&fee, &mut errors);
assert!(errors.is_empty());
}
#[test]
fn validate_replaced_order_uid_valid() {
let mut errors = Vec::new();
let uid = format!("0x{}", "ab".repeat(56));
validate_replaced_order_uid(&uid, &mut errors);
assert!(errors.is_empty());
}
#[test]
fn validate_replaced_order_uid_invalid() {
let mut errors = Vec::new();
validate_replaced_order_uid("0xshort", &mut errors);
assert!(errors.iter().any(|e| matches!(e, ValidationError::InvalidReplacedOrderUid(_))));
}
#[test]
fn validation_error_display_all_variants() {
let err = ValidationError::InvalidAppCode("test".into());
assert!(err.to_string().contains("test"));
let err = ValidationError::InvalidVersion("bad".into());
assert!(err.to_string().contains("bad"));
let err = ValidationError::InvalidHookTarget { hook: "foo".into(), reason: "bar".into() };
assert!(err.to_string().contains("foo"));
let err = ValidationError::InvalidHookGasLimit { gas_limit: "xyz".into() };
assert!(err.to_string().contains("xyz"));
let err = ValidationError::PartnerFeeBpsTooHigh(20_000);
assert!(err.to_string().contains("20000"));
let err = ValidationError::UnknownOrderClass("unknown".into());
assert!(err.to_string().contains("unknown"));
let err = ValidationError::InvalidReplacedOrderUid("0xshort".into());
assert!(err.to_string().contains("0xshort"));
let err = ValidationError::SchemaViolation { path: "/foo".into(), message: "bad".into() };
assert!(err.to_string().contains("/foo"));
let err = ValidationError::SchemaViolation { path: String::new(), message: "root".into() };
assert!(err.to_string().contains("root"));
assert!(!err.to_string().contains(" at "));
}
#[test]
fn validate_metadata_with_order_class() {
let mut errors = Vec::new();
let meta = Metadata {
order_class: Some(OrderClass { order_class: OrderClassKind::Market }),
..Metadata::default()
};
validate_metadata(&meta, &mut errors);
assert!(errors.is_empty());
}
#[test]
fn validate_metadata_with_replaced_order() {
let mut errors = Vec::new();
let uid = format!("0x{}", "ab".repeat(56));
let meta = Metadata { replaced_order: Some(ReplacedOrder { uid }), ..Metadata::default() };
validate_metadata(&meta, &mut errors);
assert!(errors.is_empty());
}
}