use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use crate::action::{Action, Chain, Request};
use crate::audit::{DenyReason, NotAllowlisted};
use crate::caps::Caps;
use crate::deny::{
SEL_TRANSFER, SEL_TRANSFER_FROM, SYS_TRANSFER, TOK_TRANSFER, TOK_TRANSFER_CHECKED,
system_discriminant, tok_discriminant, word_addr,
};
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Allowlist {
pub chains: BTreeSet<Chain>,
pub evm_contracts: BTreeMap<String, BTreeSet<u32>>,
pub solana_programs: BTreeSet<String>,
pub destinations: BTreeSet<String>,
pub spenders: BTreeSet<String>,
}
impl Allowlist {
#[must_use]
pub fn chain(mut self, chain: Chain) -> Self {
self.chains.insert(chain);
self
}
#[must_use]
pub fn contract(mut self, address: &str, selectors: &[u32]) -> Self {
self.evm_contracts
.entry(address.to_ascii_lowercase())
.or_default()
.extend(selectors.iter().copied());
self
}
#[must_use]
pub fn program(mut self, program_id: &str) -> Self {
self.solana_programs.insert(program_id.to_owned());
self
}
#[must_use]
pub fn destination(mut self, address: &str) -> Self {
self.destinations.insert(address.to_owned());
self
}
#[must_use]
pub fn spender(mut self, address: &str) -> Self {
self.spenders.insert(address.to_owned());
self
}
#[must_use]
pub fn names_destination(&self, address: &str, evm: bool) -> bool {
self.destinations.contains(address)
|| (evm
&& self
.destinations
.iter()
.any(|a| a.eq_ignore_ascii_case(address)))
}
#[must_use]
pub fn names_spender(&self, address: &str) -> bool {
self.spenders.contains(address)
|| self
.spenders
.iter()
.any(|a| a.eq_ignore_ascii_case(address))
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SubjectPolicy {
#[serde(flatten)]
pub allowlist: Allowlist,
#[serde(default)]
pub caps: Option<Caps>,
#[serde(default)]
pub auto_revoke: bool,
}
impl SubjectPolicy {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn chain(mut self, chain: Chain) -> Self {
self.allowlist = self.allowlist.chain(chain);
self
}
#[must_use]
pub fn contract(mut self, address: &str, selectors: &[u32]) -> Self {
self.allowlist = self.allowlist.contract(address, selectors);
self
}
#[must_use]
pub fn program(mut self, program_id: &str) -> Self {
self.allowlist = self.allowlist.program(program_id);
self
}
#[must_use]
pub fn destination(mut self, address: &str) -> Self {
self.allowlist = self.allowlist.destination(address);
self
}
#[must_use]
pub fn spender(mut self, address: &str) -> Self {
self.allowlist = self.allowlist.spender(address);
self
}
#[must_use]
pub fn caps(mut self, caps: Caps) -> Self {
self.caps = Some(caps);
self
}
#[must_use]
pub fn auto_revoke(mut self, yes: bool) -> Self {
self.auto_revoke = yes;
self
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Policy {
#[serde(flatten)]
pub allowlist: Allowlist,
pub subjects: BTreeMap<String, SubjectPolicy>,
}
impl Policy {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn subject(mut self, id: &str, policy: SubjectPolicy) -> Self {
self.subjects.insert(id.to_owned(), policy);
self
}
#[must_use]
pub fn chain(mut self, chain: Chain) -> Self {
self.allowlist = self.allowlist.chain(chain);
self
}
#[must_use]
pub fn contract(mut self, address: &str, selectors: &[u32]) -> Self {
self.allowlist = self.allowlist.contract(address, selectors);
self
}
#[must_use]
pub fn program(mut self, program_id: &str) -> Self {
self.allowlist = self.allowlist.program(program_id);
self
}
#[must_use]
pub fn destination(mut self, address: &str) -> Self {
self.allowlist = self.allowlist.destination(address);
self
}
#[must_use]
pub fn spender(mut self, address: &str) -> Self {
self.allowlist = self.allowlist.spender(address);
self
}
fn subject_of(&self, subject: &str) -> Option<&SubjectPolicy> {
self.subjects.get(subject)
}
fn allowed(&self, subject: &str, f: impl Fn(&Allowlist) -> bool) -> bool {
self.subject_of(subject).is_some_and(|s| f(&s.allowlist)) || f(&self.allowlist)
}
#[must_use]
pub fn chain_allowed(&self, subject: &str, chain: &Chain) -> bool {
self.allowed(subject, |l| l.chains.contains(chain))
}
#[must_use]
pub fn contract_known(&self, subject: &str, contract: &str) -> bool {
let lower = contract.to_ascii_lowercase();
self.allowed(subject, |l| l.evm_contracts.contains_key(&lower))
}
#[must_use]
pub fn selector_allowed(&self, subject: &str, contract: &str, selector: u32) -> bool {
let lower = contract.to_ascii_lowercase();
self.allowed(subject, |l| {
l.evm_contracts
.get(&lower)
.is_some_and(|sels| sels.contains(&selector))
})
}
#[must_use]
pub fn program_allowed(&self, subject: &str, program: &str) -> bool {
self.allowed(subject, |l| l.solana_programs.contains(program))
}
#[must_use]
pub fn destination_allowed(&self, subject: &str, address: &str, evm: bool) -> bool {
self.allowed(subject, |l| l.names_destination(address, evm))
}
#[must_use]
pub fn spender_allowed(&self, subject: &str, spender: &str) -> bool {
self.allowed(subject, |l| l.names_spender(spender))
}
#[must_use]
pub fn caps_for(&self, subject: &str) -> Option<&Caps> {
self.subject_of(subject).and_then(|s| s.caps.as_ref())
}
#[must_use]
pub fn auto_revokes(&self, subject: &str) -> bool {
self.subject_of(subject).is_some_and(|s| s.auto_revoke)
}
}
pub fn allowlist_denies(req: &Request, policy: &Policy) -> Vec<DenyReason> {
let subject = &req.subject;
let mut out = Vec::new();
if !policy.chain_allowed(subject, &req.chain()) {
out.push(DenyReason::NotAllowlisted(NotAllowlisted::Chain));
return out;
}
match &req.action {
Action::EvmTx {
to,
data,
authorizations,
..
} => {
match to {
None => out.push(DenyReason::NotAllowlisted(NotAllowlisted::Contract(
"<contract creation>".to_owned(),
))),
Some(target) if data.is_empty() => {
if !policy.destination_allowed(subject, target, true) {
out.push(DenyReason::NotAllowlisted(NotAllowlisted::Destination(
target.clone(),
)));
}
}
Some(target) => {
let Some(sel) = selector_of(data) else {
out.push(DenyReason::NotAllowlisted(NotAllowlisted::Selector(
"<malformed calldata>".to_owned(),
)));
return out;
};
if !policy.contract_known(subject, target) {
out.push(DenyReason::NotAllowlisted(NotAllowlisted::Contract(
target.clone(),
)));
} else if !policy.selector_allowed(subject, target, sel) {
out.push(DenyReason::NotAllowlisted(NotAllowlisted::Selector(
format!("0x{sel:08x}"),
)));
}
let recipient_at: Option<usize> = match sel {
SEL_TRANSFER => Some(4),
SEL_TRANSFER_FROM => Some(36),
_ => None,
};
if let Some(at) = recipient_at
&& let Some(addr) = word_addr(data, at)
&& !policy.destination_allowed(subject, &addr, true)
{
out.push(DenyReason::NotAllowlisted(NotAllowlisted::Destination(
addr,
)));
}
}
}
for a in authorizations {
if !policy.contract_known(subject, &a.address) {
out.push(DenyReason::NotAllowlisted(NotAllowlisted::Delegate(
a.address.clone(),
)));
}
}
}
Action::EvmTypedData {
verifying_contract, ..
} => {
if !policy.contract_known(subject, verifying_contract) {
out.push(DenyReason::NotAllowlisted(NotAllowlisted::Contract(
verifying_contract.clone(),
)));
}
}
Action::SolanaTx { instructions, .. } => {
for ins in instructions {
if !policy.program_allowed(subject, &ins.program_id) {
out.push(DenyReason::NotAllowlisted(NotAllowlisted::Program(
ins.program_id.clone(),
)));
continue;
}
if let Some(dest) = solana_destination(ins)
&& !policy.destination_allowed(subject, &dest, false)
{
out.push(DenyReason::NotAllowlisted(NotAllowlisted::Destination(
dest,
)));
}
}
}
}
out
}
fn selector_of(data: &[u8]) -> Option<u32> {
data.first_chunk::<4>().map(|s| u32::from_be_bytes(*s))
}
#[must_use]
pub fn solana_destination(ins: &crate::action::SolanaInstruction) -> Option<String> {
if ins.program_id == crate::deny::SYSTEM_PROGRAM {
if system_discriminant(ins) == SYS_TRANSFER {
return ins.accounts.get(1).cloned();
}
return None;
}
if ins.program_id == crate::deny::SPL_TOKEN_PROGRAM
|| ins.program_id == crate::deny::SPL_TOKEN_2022_PROGRAM
{
return match tok_discriminant(ins) {
d if d == TOK_TRANSFER => ins.accounts.get(1).cloned(),
d if d == TOK_TRANSFER_CHECKED => ins.accounts.get(2).cloned(),
_ => None,
};
}
None
}