use std::collections::BTreeSet;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use async_trait::async_trait;
use crate::crypto;
use crate::keys::{KeyRef, Subject};
use crate::payload::{Chain, Intent};
use crate::port::SignerError;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SignContext {
pub subject: Subject,
pub key_ref: KeyRef,
pub payload_hash: [u8; 32],
pub intent: Intent,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PolicyDecision {
Allow,
Deny {
reason: String,
},
}
impl PolicyDecision {
#[must_use]
pub fn is_allow(&self) -> bool {
matches!(self, Self::Allow)
}
}
#[async_trait]
pub trait Guardrails: Send + Sync {
async fn check(&self, context: &SignContext) -> Result<PolicyDecision, SignerError>;
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct EvmRule {
chain_id: u64,
to: Option<String>,
selector: Option<[u8; 4]>,
verifying_contract: Option<String>,
}
#[derive(Clone, Debug, Default)]
pub struct StaticGuardrails {
kill_switch: Arc<AtomicBool>,
max_value: Option<u128>,
evm: Vec<EvmRule>,
programs: BTreeSet<String>,
}
impl StaticGuardrails {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn allow_evm(
mut self,
chain_id: u64,
to: &str,
selector: [u8; 4],
) -> Result<Self, SignerError> {
let to = crypto::parse_address(to)?;
self.evm.push(EvmRule {
chain_id,
to: Some(format!("0x{}", hex::encode(to))),
selector: Some(selector),
verifying_contract: None,
});
Ok(self)
}
#[must_use]
pub fn allow_evm_any_recipient(mut self, chain_id: u64, selector: [u8; 4]) -> Self {
self.evm.push(EvmRule {
chain_id,
to: None,
selector: Some(selector),
verifying_contract: None,
});
self
}
pub fn allow_eip712(
mut self,
chain_id: u64,
verifying_contract: &str,
) -> Result<Self, SignerError> {
let contract = crypto::parse_address(verifying_contract)?;
self.evm.push(EvmRule {
chain_id,
to: None,
selector: None,
verifying_contract: Some(format!("0x{}", hex::encode(contract))),
});
Ok(self)
}
pub fn allow_program(mut self, program: &str) -> Result<Self, SignerError> {
crypto::parse_base58_pubkey(program)?;
self.programs.insert(program.to_owned());
Ok(self)
}
#[must_use]
pub fn max_value(mut self, max_wei: u128) -> Self {
self.max_value = Some(max_wei);
self
}
pub fn trip(&self) {
self.kill_switch.store(true, Ordering::SeqCst);
}
pub fn clear(&self) {
self.kill_switch.store(false, Ordering::SeqCst);
}
#[must_use]
pub fn tripped(&self) -> bool {
self.kill_switch.load(Ordering::SeqCst)
}
}
#[async_trait]
impl Guardrails for StaticGuardrails {
async fn check(&self, context: &SignContext) -> Result<PolicyDecision, SignerError> {
if self.tripped() {
return Ok(PolicyDecision::Deny {
reason: "the kill switch is tripped".to_owned(),
});
}
match context.intent.chain {
Chain::Evm { chain_id } => self.check_evm(chain_id, &context.intent),
Chain::Solana => Ok(self.check_solana(&context.intent)),
}
}
}
impl StaticGuardrails {
fn check_evm(&self, chain_id: u64, intent: &Intent) -> Result<PolicyDecision, SignerError> {
if let Some(value) = &intent.value {
let value: u128 = value.parse().map_err(|_| {
SignerError::Payload(format!(
"the intent's value `{value}` is not a wei amount this guardrail can compare"
))
})?;
if let Some(max) = self.max_value
&& value > max
{
return Ok(PolicyDecision::Deny {
reason: format!("the value {value} wei is above the cap of {max} wei"),
});
}
}
let selector = match &intent.selector {
Some(selector) => {
let bytes = selector
.strip_prefix("0x")
.and_then(|rest| hex::decode(rest).ok());
match bytes {
Some(bytes) if bytes.len() == 4 => {
Some(<[u8; 4]>::try_from(bytes).expect("length checked above"))
}
_ => {
return Ok(PolicyDecision::Deny {
reason: format!("the selector `{selector}` is not four bytes"),
});
}
}
}
None => None,
};
let matched = self.evm.iter().any(|rule| {
rule.chain_id == chain_id
&& rule
.to
.as_ref()
.is_none_or(|to| Some(to) == intent.to.as_ref())
&& rule
.selector
.as_ref()
.is_none_or(|rule_selector| Some(rule_selector) == selector.as_ref())
&& rule
.verifying_contract
.as_ref()
.is_none_or(|contract| Some(contract) == intent.verifying_contract.as_ref())
});
if matched {
return Ok(PolicyDecision::Allow);
}
Ok(PolicyDecision::Deny {
reason: format!(
"no allowlist entry covers {}{}{}",
Chain::Evm { chain_id },
intent
.to
.as_ref()
.map(|to| format!(" to {to}"))
.unwrap_or_default(),
intent
.selector
.as_ref()
.map(|selector| format!(" calling {selector}"))
.unwrap_or_default(),
),
})
}
fn check_solana(&self, intent: &Intent) -> PolicyDecision {
if intent.programs.is_empty() {
return PolicyDecision::Deny {
reason: "the message resolves no program ids to allow".to_owned(),
};
}
if let Some(unknown) = intent
.programs
.iter()
.find(|program| !self.programs.contains(*program))
{
return PolicyDecision::Deny {
reason: format!("the program `{unknown}` is not on the allowlist"),
};
}
PolicyDecision::Allow
}
}