extern crate alloc;
use alloc::vec::Vec;
use serde::{Deserialize, Serialize};
use aethel_core::signing::purpose;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Rail {
Eip3009Base,
Eip3009Ethereum,
Eip3009Arbitrum,
FheInternal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpendPolicy {
pub max_per_call: u128,
pub daily_cap: u128,
pub allow_rails: Vec<Rail>,
pub hitl_above: u128,
pub ttl_secs: u64,
pub hitl_approver_pk: Option<Vec<u8>>,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct PolicyState {
pub day_start_unix: u64,
pub spent_today: u128,
}
#[derive(Debug, Clone)]
pub struct SpendRequest {
pub rail: Rail,
pub amount: u128,
pub to: [u8; 20],
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Decision {
Allow,
RequireHitl,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PolicyViolation {
ExceedsMaxPerCall,
ExceedsDailyCap,
RailNotAllowed,
}
const DAY_SECONDS: u64 = 86_400;
impl SpendPolicy {
pub fn check(
&self,
state: &PolicyState,
req: &SpendRequest,
now_unix: u64,
) -> Result<Decision, PolicyViolation> {
if !self.allow_rails.contains(&req.rail) {
return Err(PolicyViolation::RailNotAllowed);
}
if req.amount > self.max_per_call {
return Err(PolicyViolation::ExceedsMaxPerCall);
}
let spent_today = effective_spent_today(state, now_unix);
let projected = spent_today.saturating_add(req.amount);
if projected > self.daily_cap {
return Err(PolicyViolation::ExceedsDailyCap);
}
if req.amount > self.hitl_above {
Ok(Decision::RequireHitl)
} else {
Ok(Decision::Allow)
}
}
}
fn effective_spent_today(state: &PolicyState, now_unix: u64) -> u128 {
if now_unix.saturating_sub(state.day_start_unix) >= DAY_SECONDS {
0
} else {
state.spent_today
}
}
impl PolicyState {
pub fn record(&mut self, amount: u128, now_unix: u64) {
if now_unix.saturating_sub(self.day_start_unix) >= DAY_SECONDS {
self.day_start_unix = now_unix;
self.spent_today = 0;
}
self.spent_today = self.spent_today.saturating_add(amount);
}
}
pub fn hitl_approval_message(intent_hash: &[u8; 32], amount: u128, expiry_unix: u64) -> Vec<u8> {
let mut msg = Vec::with_capacity(32 + 16 + 8);
msg.extend_from_slice(intent_hash);
msg.extend_from_slice(&amount.to_be_bytes());
msg.extend_from_slice(&expiry_unix.to_be_bytes());
msg
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HitlApproval {
pub approver_pk: Vec<u8>,
pub signature: Vec<u8>,
pub intent_hash: [u8; 32],
pub amount: u128,
pub expiry_unix: u64,
}
impl HitlApproval {
pub fn verify(
&self,
expected_intent_hash: &[u8; 32],
expected_amount: u128,
expected_approver_pk: Option<&[u8]>,
now_unix: u64,
) -> Result<bool, aethel_core::IdentityError> {
if now_unix >= self.expiry_unix {
return Ok(false);
}
if &self.intent_hash != expected_intent_hash || self.amount != expected_amount {
return Ok(false);
}
match expected_approver_pk {
Some(expected_pk) if self.approver_pk == expected_pk => {}
_ => return Ok(false),
}
let msg = hitl_approval_message(&self.intent_hash, self.amount, self.expiry_unix);
aethel_core::signing::verify_with_purpose(
&self.approver_pk,
purpose::VAULT_HITL_APPROVAL_V1,
&msg,
&self.signature,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use aethel_core::signing::Identity;
fn policy(hitl_approver_pk: Option<Vec<u8>>) -> SpendPolicy {
SpendPolicy {
max_per_call: 50_000, daily_cap: 2_000_000, allow_rails: alloc::vec![Rail::Eip3009Base],
hitl_above: 10_000_000, ttl_secs: 3600,
hitl_approver_pk,
}
}
fn hitl_policy(hitl_approver_pk: Option<Vec<u8>>) -> SpendPolicy {
SpendPolicy {
max_per_call: 1_000_000, daily_cap: 2_000_000, allow_rails: alloc::vec![Rail::Eip3009Base],
hitl_above: 500_000, ttl_secs: 3600,
hitl_approver_pk,
}
}
fn req(amount: u128) -> SpendRequest {
SpendRequest {
rail: Rail::Eip3009Base,
amount,
to: [0xAAu8; 20],
}
}
#[test]
fn over_max_per_call_denied() {
let p = policy(None);
let st = PolicyState::default();
assert_eq!(
p.check(&st, &req(50_001), 1_000),
Err(PolicyViolation::ExceedsMaxPerCall)
);
}
#[test]
fn at_max_per_call_is_allowed() {
let p = policy(None);
let st = PolicyState::default();
assert_eq!(p.check(&st, &req(50_000), 1_000), Ok(Decision::Allow));
}
#[test]
fn daily_cap_accumulates_and_resets_after_86400s() {
let p = policy(None);
let mut st = PolicyState::default();
for _ in 0..39 {
let d = p.check(&st, &req(50_000), 1_000).expect("allowed");
assert_eq!(d, Decision::Allow);
st.record(50_000, 1_000);
}
assert_eq!(st.spent_today, 1_950_000);
assert_eq!(p.check(&st, &req(50_000), 1_000), Ok(Decision::Allow));
st.record(50_000, 1_000);
assert_eq!(st.spent_today, 2_000_000);
assert_eq!(
p.check(&st, &req(1), 1_000),
Err(PolicyViolation::ExceedsDailyCap)
);
let later = 1_000 + 86_400;
assert_eq!(p.check(&st, &req(50_000), later), Ok(Decision::Allow));
}
#[test]
fn amount_above_hitl_requires_approval() {
let p = hitl_policy(None);
let st = PolicyState::default();
assert_eq!(
p.check(&st, &req(500_001), 1_000),
Ok(Decision::RequireHitl)
);
}
#[test]
fn amount_at_hitl_threshold_does_not_require_approval() {
let p = hitl_policy(None);
let st = PolicyState::default();
assert_eq!(p.check(&st, &req(500_000), 1_000), Ok(Decision::Allow));
}
#[test]
fn disallowed_rail_denied() {
let p = policy(None);
let st = PolicyState::default();
let mut r = req(1);
r.rail = Rail::Eip3009Ethereum;
assert_eq!(
p.check(&st, &r, 1_000),
Err(PolicyViolation::RailNotAllowed)
);
}
#[test]
fn rail_check_takes_priority_over_amount_checks() {
let p = policy(None);
let st = PolicyState::default();
let mut r = req(999_999_999); r.rail = Rail::FheInternal;
assert_eq!(
p.check(&st, &r, 1_000),
Err(PolicyViolation::RailNotAllowed)
);
}
#[test]
fn valid_hitl_approval_unblocks() {
let approver = Identity::generate(&[0x42u8; 32]).expect("generate");
let intent_hash = [0x11u8; 32];
let amount = 500_001u128;
let expiry = 10_000u64;
let msg = hitl_approval_message(&intent_hash, amount, expiry);
let sig = approver
.sign_with_purpose(purpose::VAULT_HITL_APPROVAL_V1, &msg)
.expect("sign");
let approval = HitlApproval {
approver_pk: approver.public_key(),
signature: sig,
intent_hash,
amount,
expiry_unix: expiry,
};
assert_eq!(
approval.verify(&intent_hash, amount, Some(&approver.public_key()), 1_000),
Ok(true)
);
}
#[test]
fn wrong_approver_key_does_not_unblock() {
let approver = Identity::generate(&[0x42u8; 32]).expect("generate");
let other = Identity::generate(&[0x43u8; 32]).expect("generate");
let intent_hash = [0x22u8; 32];
let amount = 500_001u128;
let expiry = 10_000u64;
let msg = hitl_approval_message(&intent_hash, amount, expiry);
let sig = approver
.sign_with_purpose(purpose::VAULT_HITL_APPROVAL_V1, &msg)
.expect("sign");
let approval = HitlApproval {
approver_pk: approver.public_key(),
signature: sig,
intent_hash,
amount,
expiry_unix: expiry,
};
assert_eq!(
approval.verify(&intent_hash, amount, Some(&other.public_key()), 1_000),
Ok(false)
);
}
#[test]
fn expired_approval_does_not_unblock() {
let approver = Identity::generate(&[0x42u8; 32]).expect("generate");
let intent_hash = [0x33u8; 32];
let amount = 500_001u128;
let expiry = 1_000u64;
let msg = hitl_approval_message(&intent_hash, amount, expiry);
let sig = approver
.sign_with_purpose(purpose::VAULT_HITL_APPROVAL_V1, &msg)
.expect("sign");
let approval = HitlApproval {
approver_pk: approver.public_key(),
signature: sig,
intent_hash,
amount,
expiry_unix: expiry,
};
assert_eq!(
approval.verify(
&intent_hash,
amount,
Some(&approver.public_key()),
expiry
),
Ok(false)
);
}
#[test]
fn approval_for_a_different_amount_does_not_unblock() {
let approver = Identity::generate(&[0x42u8; 32]).expect("generate");
let intent_hash = [0x44u8; 32];
let expiry = 10_000u64;
let msg = hitl_approval_message(&intent_hash, 500_001, expiry);
let sig = approver
.sign_with_purpose(purpose::VAULT_HITL_APPROVAL_V1, &msg)
.expect("sign");
let approval = HitlApproval {
approver_pk: approver.public_key(),
signature: sig,
intent_hash,
amount: 500_001,
expiry_unix: expiry,
};
assert_eq!(
approval.verify(&intent_hash, 600_000, Some(&approver.public_key()), 1_000),
Ok(false)
);
}
}