use openagent_aegis_core::{DelegationError, DelegationScope, SpendingLimits, TemporalConstraints};
pub fn is_scope_subset(child: &DelegationScope, parent: &DelegationScope) -> bool {
if !parent.actions.is_empty() && !child.actions.iter().all(|a| parent.actions.contains(a)) {
return false;
}
if !parent.resources.is_empty() && !child.resources.iter().all(|r| parent.resources.contains(r))
{
return false;
}
if !parent.chains.is_empty() && !child.chains.iter().all(|c| parent.chains.contains(c)) {
return false;
}
if let Some(ref parent_limits) = parent.limits {
match child.limits {
None => {
return false;
}
Some(ref child_limits) => {
if !is_limits_subset(child_limits, parent_limits) {
return false;
}
}
}
}
true
}
fn is_limits_subset(child: &SpendingLimits, parent: &SpendingLimits) -> bool {
if let Some(ref parent_max) = parent.max_amount {
match child.max_amount {
None => return false, Some(ref child_max) => {
if !is_amount_leq(child_max, parent_max) {
return false;
}
}
}
}
if let Some(ref parent_daily) = parent.daily_volume {
match child.daily_volume {
None => return false,
Some(ref child_daily) => {
if !is_amount_leq(child_daily, parent_daily) {
return false;
}
}
}
}
if !parent.asset_allowlist.is_empty()
&& !child
.asset_allowlist
.iter()
.all(|a| parent.asset_allowlist.contains(a))
{
return false;
}
if !parent.recipient_allowlist.is_empty()
&& !child
.recipient_allowlist
.iter()
.all(|r| parent.recipient_allowlist.contains(r))
{
return false;
}
if let Some(ref parent_thresh) = parent.approval_threshold {
match child.approval_threshold {
None => return false,
Some(ref child_thresh) => {
if !is_amount_leq(child_thresh, parent_thresh) {
return false;
}
}
}
}
true
}
fn is_amount_leq(a: &str, b: &str) -> bool {
match (a.parse::<f64>(), b.parse::<f64>()) {
(Ok(av), Ok(bv)) => av <= bv,
_ => a <= b,
}
}
pub fn intersect_scopes(a: &DelegationScope, b: &DelegationScope) -> DelegationScope {
let actions = intersect_lists(&a.actions, &b.actions);
let resources = intersect_lists(&a.resources, &b.resources);
let chains = intersect_lists(&a.chains, &b.chains);
let limits = intersect_limits(&a.limits, &b.limits);
let temporal = intersect_temporal(&a.temporal, &b.temporal);
DelegationScope {
actions,
resources,
chains,
limits,
temporal,
}
}
fn intersect_lists(a: &[String], b: &[String]) -> Vec<String> {
if a.is_empty() {
return b.to_vec();
}
if b.is_empty() {
return a.to_vec();
}
a.iter().filter(|x| b.contains(x)).cloned().collect()
}
fn intersect_limits(
a: &Option<SpendingLimits>,
b: &Option<SpendingLimits>,
) -> Option<SpendingLimits> {
match (a, b) {
(None, None) => None,
(Some(limits), None) | (None, Some(limits)) => Some(limits.clone()),
(Some(a_lim), Some(b_lim)) => Some(SpendingLimits {
max_amount: min_amount_opt(&a_lim.max_amount, &b_lim.max_amount),
daily_volume: min_amount_opt(&a_lim.daily_volume, &b_lim.daily_volume),
asset_allowlist: intersect_lists(&a_lim.asset_allowlist, &b_lim.asset_allowlist),
recipient_allowlist: intersect_lists(
&a_lim.recipient_allowlist,
&b_lim.recipient_allowlist,
),
approval_threshold: min_amount_opt(
&a_lim.approval_threshold,
&b_lim.approval_threshold,
),
}),
}
}
fn intersect_temporal(
a: &Option<TemporalConstraints>,
b: &Option<TemporalConstraints>,
) -> Option<TemporalConstraints> {
match (a, b) {
(None, None) => None,
(Some(t), None) | (None, Some(t)) => Some(t.clone()),
(Some(a_t), Some(b_t)) => {
let valid_from = match (&a_t.valid_from, &b_t.valid_from) {
(None, None) => None,
(Some(t), None) | (None, Some(t)) => Some(*t),
(Some(at), Some(bt)) => Some((*at).max(*bt)),
};
let valid_until = match (&a_t.valid_until, &b_t.valid_until) {
(None, None) => None,
(Some(t), None) | (None, Some(t)) => Some(*t),
(Some(at), Some(bt)) => Some((*at).min(*bt)),
};
let active_hours = a_t
.active_hours
.clone()
.or_else(|| b_t.active_hours.clone());
let cooldown = match (&a_t.cooldown, &b_t.cooldown) {
(None, None) => None,
(Some(c), None) | (None, Some(c)) => Some(c.clone()),
(Some(ac), Some(bc)) => {
if ac >= bc {
Some(ac.clone())
} else {
Some(bc.clone())
}
}
};
Some(TemporalConstraints {
valid_from,
valid_until,
active_hours,
cooldown,
})
}
}
}
fn min_amount_opt(a: &Option<String>, b: &Option<String>) -> Option<String> {
match (a, b) {
(None, None) => None,
(Some(v), None) | (None, Some(v)) => Some(v.clone()),
(Some(av), Some(bv)) => {
if is_amount_leq(av, bv) {
Some(av.clone())
} else {
Some(bv.clone())
}
}
}
}
pub fn validate_scope(scope: &DelegationScope) -> Result<(), DelegationError> {
for action in &scope.actions {
if action.is_empty() {
return Err(DelegationError::ScopeAmplification {
reason: "action must not be empty".to_string(),
});
}
}
for resource in &scope.resources {
if resource.is_empty() {
return Err(DelegationError::ScopeAmplification {
reason: "resource must not be empty".to_string(),
});
}
}
for chain in &scope.chains {
if chain.is_empty() {
return Err(DelegationError::ScopeAmplification {
reason: "chain must not be empty".to_string(),
});
}
}
if let Some(ref limits) = scope.limits {
validate_amount_field(&limits.max_amount, "max_amount")?;
validate_amount_field(&limits.daily_volume, "daily_volume")?;
validate_amount_field(&limits.approval_threshold, "approval_threshold")?;
}
if let Some(ref temporal) = scope.temporal {
if let (Some(from), Some(until)) = (&temporal.valid_from, &temporal.valid_until) {
if from >= until {
return Err(DelegationError::ScopeAmplification {
reason: "valid_from must be before valid_until".to_string(),
});
}
}
if let Some(ref hours) = temporal.active_hours {
if hours.start_hour > 23 || hours.end_hour > 23 {
return Err(DelegationError::ScopeAmplification {
reason: "active hours must be 0-23".to_string(),
});
}
}
}
Ok(())
}
fn validate_amount_field(field: &Option<String>, name: &str) -> Result<(), DelegationError> {
if let Some(ref val) = field {
if val.parse::<f64>().is_err() {
return Err(DelegationError::ScopeAmplification {
reason: format!("{name} is not a valid number: {val}"),
});
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use openagent_aegis_core::SpendingLimits;
fn full_scope() -> DelegationScope {
DelegationScope {
actions: vec![
"transfer".to_string(),
"approve".to_string(),
"stake".to_string(),
],
resources: vec!["0xabc".to_string(), "0xdef".to_string()],
chains: vec!["ethereum".to_string(), "polygon".to_string()],
limits: Some(SpendingLimits {
max_amount: Some("1000".to_string()),
daily_volume: Some("5000".to_string()),
asset_allowlist: vec!["ETH".to_string(), "USDC".to_string()],
recipient_allowlist: vec!["0x111".to_string()],
approval_threshold: Some("500".to_string()),
}),
temporal: None,
}
}
fn narrow_scope() -> DelegationScope {
DelegationScope {
actions: vec!["transfer".to_string()],
resources: vec!["0xabc".to_string()],
chains: vec!["ethereum".to_string()],
limits: Some(SpendingLimits {
max_amount: Some("100".to_string()),
daily_volume: Some("500".to_string()),
asset_allowlist: vec!["ETH".to_string()],
recipient_allowlist: vec!["0x111".to_string()],
approval_threshold: Some("50".to_string()),
}),
temporal: None,
}
}
#[test]
fn narrow_is_subset_of_full() {
assert!(is_scope_subset(&narrow_scope(), &full_scope()));
}
#[test]
fn full_is_not_subset_of_narrow() {
assert!(!is_scope_subset(&full_scope(), &narrow_scope()));
}
#[test]
fn scope_is_subset_of_itself() {
let s = full_scope();
assert!(is_scope_subset(&s, &s));
}
#[test]
fn empty_parent_accepts_anything() {
let parent = DelegationScope {
actions: vec![],
resources: vec![],
chains: vec![],
limits: None,
temporal: None,
};
assert!(is_scope_subset(&full_scope(), &parent));
}
#[test]
fn amplification_in_actions_rejected() {
let child = DelegationScope {
actions: vec!["transfer".to_string(), "destroy".to_string()],
resources: vec![],
chains: vec![],
limits: None,
temporal: None,
};
let parent = DelegationScope {
actions: vec!["transfer".to_string()],
resources: vec![],
chains: vec![],
limits: None,
temporal: None,
};
assert!(!is_scope_subset(&child, &parent));
}
#[test]
fn amplification_in_limits_rejected() {
let child = DelegationScope {
actions: vec!["transfer".to_string()],
resources: vec![],
chains: vec![],
limits: Some(SpendingLimits {
max_amount: Some("2000".to_string()), daily_volume: None,
asset_allowlist: vec![],
recipient_allowlist: vec![],
approval_threshold: None,
}),
temporal: None,
};
let parent = DelegationScope {
actions: vec!["transfer".to_string()],
resources: vec![],
chains: vec![],
limits: Some(SpendingLimits {
max_amount: Some("1000".to_string()),
daily_volume: None,
asset_allowlist: vec![],
recipient_allowlist: vec![],
approval_threshold: None,
}),
temporal: None,
};
assert!(!is_scope_subset(&child, &parent));
}
#[test]
fn intersection_narrows_correctly() {
let result = intersect_scopes(&full_scope(), &narrow_scope());
assert_eq!(result.actions, vec!["transfer".to_string()]);
assert_eq!(result.resources, vec!["0xabc".to_string()]);
assert_eq!(result.chains, vec!["ethereum".to_string()]);
let limits = result.limits.as_ref();
assert!(limits.is_some());
if let Some(l) = limits {
assert_eq!(l.max_amount.as_deref(), Some("100"));
assert_eq!(l.daily_volume.as_deref(), Some("500"));
assert_eq!(l.approval_threshold.as_deref(), Some("50"));
}
}
#[test]
fn intersection_with_empty_is_identity() {
let empty = DelegationScope {
actions: vec![],
resources: vec![],
chains: vec![],
limits: None,
temporal: None,
};
let result = intersect_scopes(&full_scope(), &empty);
assert_eq!(result.actions, full_scope().actions);
assert_eq!(result.resources, full_scope().resources);
}
#[test]
fn validate_scope_rejects_empty_action() {
let scope = DelegationScope {
actions: vec!["".to_string()],
resources: vec![],
chains: vec![],
limits: None,
temporal: None,
};
assert!(validate_scope(&scope).is_err());
}
#[test]
fn validate_scope_rejects_invalid_amount() {
let scope = DelegationScope {
actions: vec!["transfer".to_string()],
resources: vec![],
chains: vec![],
limits: Some(SpendingLimits {
max_amount: Some("not-a-number".to_string()),
daily_volume: None,
asset_allowlist: vec![],
recipient_allowlist: vec![],
approval_threshold: None,
}),
temporal: None,
};
assert!(validate_scope(&scope).is_err());
}
#[test]
fn validate_scope_accepts_valid() {
assert!(validate_scope(&full_scope()).is_ok());
}
}