use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TimeBound {
pub not_before: DateTime<Utc>,
pub not_after: DateTime<Utc>,
}
impl TimeBound {
pub fn new(not_before: DateTime<Utc>, not_after: DateTime<Utc>) -> Self {
Self {
not_before,
not_after,
}
}
pub fn is_active(&self) -> bool {
let now = Utc::now();
now >= self.not_before && now <= self.not_after
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DelegationScope {
pub max_transaction_value: Option<u128>,
pub max_daily_spend: Option<u128>,
pub allowed_operations: Vec<String>,
pub allowed_contracts: Vec<Vec<u8>>,
pub time_bound: Option<TimeBound>,
pub allowed_payment_protocols: Vec<String>,
pub allowed_chains: Vec<String>,
}
impl DelegationScope {
pub fn unrestricted() -> Self {
Self {
max_transaction_value: None,
max_daily_spend: None,
allowed_operations: Vec::new(),
allowed_contracts: Vec::new(),
time_bound: None,
allowed_payment_protocols: Vec::new(),
allowed_chains: Vec::new(),
}
}
pub fn with_max_transaction_value(mut self, value: u128) -> Self {
self.max_transaction_value = Some(value);
self
}
pub fn with_max_daily_spend(mut self, value: u128) -> Self {
self.max_daily_spend = Some(value);
self
}
pub fn with_allowed_operations(mut self, ops: Vec<String>) -> Self {
self.allowed_operations = ops;
self
}
pub fn with_time_bound(mut self, bound: TimeBound) -> Self {
self.time_bound = Some(bound);
self
}
pub fn with_allowed_payment_protocols(mut self, protocols: Vec<String>) -> Self {
self.allowed_payment_protocols = protocols;
self
}
pub fn with_allowed_chains(mut self, chains: Vec<String>) -> Self {
self.allowed_chains = chains;
self
}
pub fn is_active(&self) -> bool {
match &self.time_bound {
Some(bound) => bound.is_active(),
None => true,
}
}
pub fn is_operation_allowed(&self, operation: &str) -> bool {
if self.allowed_operations.is_empty() {
return true;
}
self.allowed_operations.iter().any(|op| op == operation)
}
pub fn is_value_allowed(&self, value: u128) -> bool {
match self.max_transaction_value {
Some(max) => value <= max,
None => true,
}
}
pub fn is_protocol_allowed(&self, protocol: &str) -> bool {
if self.allowed_payment_protocols.is_empty() {
return true;
}
self.allowed_payment_protocols
.iter()
.any(|p| p == protocol)
}
pub fn is_chain_allowed(&self, chain: &str) -> bool {
if self.allowed_chains.is_empty() {
return true;
}
self.allowed_chains.iter().any(|c| c == chain)
}
pub fn attenuate(&self, child: &DelegationScope) -> DelegationScope {
DelegationScope {
max_transaction_value: min_optional(
self.max_transaction_value,
child.max_transaction_value,
),
max_daily_spend: min_optional(self.max_daily_spend, child.max_daily_spend),
allowed_operations: intersect_allowlist(
&self.allowed_operations,
&child.allowed_operations,
),
allowed_contracts: intersect_allowlist(
&self.allowed_contracts,
&child.allowed_contracts,
),
time_bound: tightest_time_bound(self.time_bound.as_ref(), child.time_bound.as_ref()),
allowed_payment_protocols: intersect_allowlist(
&self.allowed_payment_protocols,
&child.allowed_payment_protocols,
),
allowed_chains: intersect_allowlist(&self.allowed_chains, &child.allowed_chains),
}
}
}
fn min_optional(a: Option<u128>, b: Option<u128>) -> Option<u128> {
match (a, b) {
(Some(x), Some(y)) => Some(x.min(y)),
(Some(x), None) => Some(x),
(None, Some(y)) => Some(y),
(None, None) => None,
}
}
fn intersect_allowlist<T: Clone + PartialEq>(parent: &[T], child: &[T]) -> Vec<T> {
if parent.is_empty() {
return child.to_vec();
}
if child.is_empty() {
return parent.to_vec();
}
parent
.iter()
.filter(|item| child.iter().any(|c| c == *item))
.cloned()
.collect()
}
fn tightest_time_bound(parent: Option<&TimeBound>, child: Option<&TimeBound>) -> Option<TimeBound> {
match (parent, child) {
(Some(p), Some(c)) => Some(TimeBound {
not_before: p.not_before.max(c.not_before),
not_after: p.not_after.min(c.not_after),
}),
(Some(p), None) => Some(p.clone()),
(None, Some(c)) => Some(c.clone()),
(None, None) => None,
}
}
impl Default for DelegationScope {
fn default() -> Self {
Self::unrestricted()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DelegationEntry {
pub delegation_id: String,
pub grantor_did: String,
pub grantee_did: String,
pub scope: DelegationScope,
pub created_at: DateTime<Utc>,
pub revoked: bool,
pub revoked_at: Option<DateTime<Utc>>,
}
impl DelegationEntry {
pub fn new(grantor_did: String, grantee_did: String, scope: DelegationScope) -> Self {
Self {
delegation_id: uuid::Uuid::new_v4().to_string(),
grantor_did,
grantee_did,
scope,
created_at: Utc::now(),
revoked: false,
revoked_at: None,
}
}
pub fn is_active(&self) -> bool {
!self.revoked && self.scope.is_active()
}
pub fn revoke(&mut self) {
self.revoked = true;
self.revoked_at = Some(Utc::now());
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_unrestricted_scope() {
let scope = DelegationScope::unrestricted();
assert!(scope.is_active());
assert!(scope.is_operation_allowed("anything"));
assert!(scope.is_value_allowed(u128::MAX));
assert!(scope.is_protocol_allowed("mpp"));
assert!(scope.is_chain_allowed("tenzro"));
}
#[test]
fn test_restricted_scope() {
let scope = DelegationScope::unrestricted()
.with_max_transaction_value(10_000)
.with_allowed_operations(vec!["inference".to_string(), "trade".to_string()])
.with_allowed_payment_protocols(vec!["mpp".to_string(), "x402".to_string()])
.with_allowed_chains(vec!["tenzro".to_string(), "tempo".to_string()]);
assert!(scope.is_value_allowed(10_000));
assert!(!scope.is_value_allowed(10_001));
assert!(scope.is_operation_allowed("inference"));
assert!(!scope.is_operation_allowed("admin"));
assert!(scope.is_protocol_allowed("mpp"));
assert!(!scope.is_protocol_allowed("direct"));
assert!(scope.is_chain_allowed("tempo"));
assert!(!scope.is_chain_allowed("ethereum"));
}
#[test]
fn test_time_bound() {
let active_bound = TimeBound::new(
Utc::now() - chrono::Duration::hours(1),
Utc::now() + chrono::Duration::hours(1),
);
assert!(active_bound.is_active());
let expired_bound = TimeBound::new(
Utc::now() - chrono::Duration::hours(2),
Utc::now() - chrono::Duration::hours(1),
);
assert!(!expired_bound.is_active());
let future_bound = TimeBound::new(
Utc::now() + chrono::Duration::hours(1),
Utc::now() + chrono::Duration::hours(2),
);
assert!(!future_bound.is_active());
}
#[test]
fn test_delegation_entry() {
let scope = DelegationScope::unrestricted()
.with_max_transaction_value(5_000);
let entry = DelegationEntry::new(
"did:tenzro:human:alice".to_string(),
"did:tenzro:machine:alice:bot1".to_string(),
scope,
);
assert!(entry.is_active());
assert!(!entry.revoked);
}
#[test]
fn test_attenuate_numeric_ceilings_take_minimum() {
let parent = DelegationScope::unrestricted()
.with_max_transaction_value(10_000)
.with_max_daily_spend(100_000);
let child = DelegationScope::unrestricted()
.with_max_transaction_value(5_000)
.with_max_daily_spend(50_000);
let merged = parent.attenuate(&child);
assert_eq!(merged.max_transaction_value, Some(5_000));
assert_eq!(merged.max_daily_spend, Some(50_000));
let merged_rev = child.attenuate(&parent);
assert_eq!(merged_rev.max_transaction_value, Some(5_000));
}
#[test]
fn test_attenuate_unlimited_side_yields_other() {
let parent = DelegationScope::unrestricted().with_max_transaction_value(1_000);
let child = DelegationScope::unrestricted(); let merged = parent.attenuate(&child);
assert_eq!(merged.max_transaction_value, Some(1_000));
let both_unlimited = DelegationScope::unrestricted();
assert_eq!(
both_unlimited.attenuate(&both_unlimited).max_transaction_value,
None
);
}
#[test]
fn test_attenuate_allowlist_intersection() {
let parent = DelegationScope::unrestricted()
.with_allowed_operations(vec!["inference".into(), "trade".into(), "transfer".into()]);
let child = DelegationScope::unrestricted()
.with_allowed_operations(vec!["trade".into(), "borrow".into()]);
let merged = parent.attenuate(&child);
assert_eq!(merged.allowed_operations, vec!["trade".to_string()]);
}
#[test]
fn test_attenuate_empty_allowlist_means_unrestricted_on_that_side() {
let parent = DelegationScope::unrestricted()
.with_allowed_operations(vec!["inference".into()]);
let child = DelegationScope::unrestricted();
let merged = parent.attenuate(&child);
assert_eq!(merged.allowed_operations, vec!["inference".to_string()]);
let both_empty = DelegationScope::unrestricted();
assert!(both_empty
.attenuate(&both_empty)
.allowed_operations
.is_empty());
}
#[test]
fn test_attenuate_disjoint_allowlists_yield_empty_intersection() {
let parent = DelegationScope::unrestricted()
.with_allowed_operations(vec!["inference".into()]);
let child = DelegationScope::unrestricted()
.with_allowed_operations(vec!["admin".into()]);
let merged = parent.attenuate(&child);
assert!(merged.allowed_operations.is_empty());
assert!(merged.is_operation_allowed("anything"));
}
#[test]
fn test_attenuate_time_bound_takes_tightest_window() {
let now = Utc::now();
let parent = DelegationScope::unrestricted().with_time_bound(TimeBound::new(
now - chrono::Duration::hours(1),
now + chrono::Duration::hours(10),
));
let child = DelegationScope::unrestricted().with_time_bound(TimeBound::new(
now + chrono::Duration::hours(1),
now + chrono::Duration::hours(5),
));
let merged = parent.attenuate(&child);
let bound = merged.time_bound.expect("merged time_bound");
assert_eq!(bound.not_before, now + chrono::Duration::hours(1));
assert_eq!(bound.not_after, now + chrono::Duration::hours(5));
}
#[test]
fn test_attenuate_one_sided_time_bound_propagates() {
let now = Utc::now();
let parent = DelegationScope::unrestricted();
let child = DelegationScope::unrestricted().with_time_bound(TimeBound::new(
now,
now + chrono::Duration::hours(1),
));
let merged = parent.attenuate(&child);
assert!(merged.time_bound.is_some());
}
#[test]
fn test_attenuate_protocols_and_chains_intersected() {
let parent = DelegationScope::unrestricted()
.with_allowed_payment_protocols(vec!["mpp".into(), "x402".into()])
.with_allowed_chains(vec!["tenzro".into(), "tempo".into()]);
let child = DelegationScope::unrestricted()
.with_allowed_payment_protocols(vec!["x402".into()])
.with_allowed_chains(vec!["tempo".into(), "ethereum".into()]);
let merged = parent.attenuate(&child);
assert_eq!(merged.allowed_payment_protocols, vec!["x402".to_string()]);
assert_eq!(merged.allowed_chains, vec!["tempo".to_string()]);
}
#[test]
fn test_revoke_delegation() {
let scope = DelegationScope::default();
let mut entry = DelegationEntry::new(
"did:tenzro:human:alice".to_string(),
"did:tenzro:machine:alice:bot1".to_string(),
scope,
);
assert!(entry.is_active());
entry.revoke();
assert!(!entry.is_active());
assert!(entry.revoked);
assert!(entry.revoked_at.is_some());
}
}