use cratefield_signer::SignerError;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AllowRule {
Evm {
chain_id: u64,
to: EvmAddress,
selector: Option<Selector>,
max_value: Option<u128>,
},
Solana {
program_key: ProgramKey,
},
}
impl AllowRule {
pub fn evm(
chain_id: u64,
to: &str,
selector: Option<&str>,
max_value: Option<u128>,
) -> Result<Self, SignerError> {
if let Some(max_value) = max_value {
if max_value > i128::MAX as u128 {
return Err(SignerError::Invalid(
"a value cap must fit the policy engine's i128".to_owned(),
));
}
}
Ok(Self::Evm {
chain_id,
to: EvmAddress::new(to)?,
selector: selector.map(Selector::new).transpose()?,
max_value,
})
}
pub fn solana(program_key: &str) -> Result<Self, SignerError> {
Ok(Self::Solana {
program_key: ProgramKey::new(program_key)?,
})
}
#[must_use]
pub fn condition(&self) -> String {
match self {
Self::Evm {
chain_id,
to,
selector,
max_value,
} => {
let mut clauses = vec![
format!("eth.tx.to == '{}'", to.as_str()),
format!("eth.tx.chain_id == {chain_id}"),
];
if let Some(selector) = selector {
clauses.push(format!("eth.tx.data[0..10] == '{}'", selector.as_str()));
}
if let Some(max_value) = max_value {
clauses.push(format!("eth.tx.value <= {max_value}"));
}
clauses.join(" && ")
}
Self::Solana { program_key } => format!(
"solana.tx.instructions.all(i, i.program_key == '{}')",
program_key.as_str()
),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EvmAddress(String);
impl EvmAddress {
pub fn new(address: &str) -> Result<Self, SignerError> {
validate_address(address).map(Self)
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Selector(String);
impl Selector {
pub fn new(selector: &str) -> Result<Self, SignerError> {
validate_selector(selector).map(Self)
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProgramKey(String);
impl ProgramKey {
pub fn new(program_key: &str) -> Result<Self, SignerError> {
validate_program_key(program_key).map(Self)
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
pub fn da_consensus(da_user_id: &str) -> Result<String, SignerError> {
validate_turnkey_id(da_user_id)?;
Ok(format!("approvers.any(user, user.id == '{da_user_id}')"))
}
pub fn validate_address(address: &str) -> Result<String, SignerError> {
hex_word(address, 40).map_err(|reason| {
SignerError::Invalid(format!(
"an EVM address is 0x + 40 hex, got `{address}`: {reason}"
))
})
}
pub fn validate_selector(selector: &str) -> Result<String, SignerError> {
hex_word(selector, 8).map_err(|reason| {
SignerError::Invalid(format!(
"a selector is 0x + 8 hex, got `{selector}`: {reason}"
))
})
}
pub fn validate_program_key(program_key: &str) -> Result<String, SignerError> {
match bs58::decode(program_key).into_vec() {
Ok(bytes) if bytes.len() == 32 => Ok(program_key.to_owned()),
Ok(bytes) => Err(SignerError::Invalid(format!(
"a Solana program key decodes to 32 bytes, `{program_key}` decodes to {}",
bytes.len()
))),
Err(err) => Err(SignerError::Invalid(format!(
"a Solana program key is base58, got `{program_key}`: {err}"
))),
}
}
pub fn validate_turnkey_id(id: &str) -> Result<String, SignerError> {
let valid = id.len() == 36
&& id.as_bytes()[8] == b'-'
&& id.as_bytes()[13] == b'-'
&& id.as_bytes()[18] == b'-'
&& id.as_bytes()[23] == b'-'
&& id
.bytes()
.enumerate()
.all(|(at, byte)| matches!(at, 8 | 13 | 18 | 23) || byte.is_ascii_hexdigit());
if valid {
Ok(id.to_owned())
} else {
Err(SignerError::Invalid(format!(
"a Turnkey id is a UUID, got `{id}`"
)))
}
}
fn hex_word(text: &str, digits: usize) -> Result<String, String> {
let body = text.strip_prefix("0x").ok_or("missing the 0x prefix")?;
if body.len() != digits {
return Err(format!(
"{digits} hex digits expected, found {}",
body.len()
));
}
if !body.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return Err("non-hex character".to_owned());
}
Ok(format!("0x{}", body.to_ascii_lowercase()))
}
#[cfg(test)]
mod tests {
use super::*;
const ADDRESS: &str = "0x000000000000000000000000000000000000aaaa";
const TOKEN_PROGRAM: &str = concat!("TokenkegQfeZ", "yiNwAJbNbGKP", "FXCWuBvf9Ss", "623VQ5DA");
#[test]
fn the_evm_condition_names_to_chain_selector_and_cap() {
let rule =
AllowRule::evm(1, ADDRESS, Some("0xA9059CBB"), Some(1_000_000)).expect("a valid rule");
assert_eq!(
rule.condition(),
concat!(
"eth.tx.to == '0x000000000000000000000000000000000000aaaa'",
" && eth.tx.chain_id == 1",
" && eth.tx.data[0..10] == '0xa9059cbb'",
" && eth.tx.value <= 1000000"
)
);
}
#[test]
fn the_solana_condition_binds_every_instruction() {
let rule = AllowRule::solana(TOKEN_PROGRAM).expect("a valid program key");
assert_eq!(
rule.condition(),
format!("solana.tx.instructions.all(i, i.program_key == '{TOKEN_PROGRAM}')")
);
}
#[test]
fn the_consensus_scopes_to_the_delegated_user() {
let id = "11111111-2222-3333-4444-555555555555";
assert_eq!(
da_consensus(id).expect("a valid id"),
format!("approvers.any(user, user.id == '{id}')")
);
}
#[test]
fn validators_reject_injection_and_typo_shapes() {
for bad in [
"' OR '1'=='1", "0xAAAA", "0x000000000000000000000000000000000000aaa", "0x000000000000000000000000000000000000aaag", "000000000000000000000000000000000000aaaa", ] {
assert!(validate_address(bad).is_err(), "`{bad}` must not pass");
}
for bad in [
"0xA9059CBB0", "0xa9059c", "\"0xa9059cbb\"", ] {
assert!(validate_selector(bad).is_err(), "`{bad}` must not pass");
}
for bad in [
concat!(
"TokenkegQfeZ",
"yiNwAJbNbGKP",
"FXCWuBvf9Ss",
"623VQ5DA",
"A"
),
"0xdeadbeef", "l1O0placeholderl1O0placeholderl1O0placeholderxx", "' OR '1'=='1",
] {
assert!(validate_program_key(bad).is_err(), "`{bad}` must not pass");
}
for bad in [
"not-a-uuid",
"11111111-2222-3333-4444-55555555555", "11111111-2222-3333-4444-55555555555'", "111111112222333344445555555555555555", ] {
assert!(validate_turnkey_id(bad).is_err(), "`{bad}` must not pass");
}
}
#[test]
fn validators_normalise_hex_case_and_keep_base58_exactly() {
assert_eq!(
validate_address("0x000000000000000000000000000000000000AAAA").expect("valid"),
ADDRESS
);
assert_eq!(
validate_selector("0xA9059CBB").expect("valid"),
"0xa9059cbb"
);
assert_eq!(
validate_program_key(TOKEN_PROGRAM).expect("valid"),
TOKEN_PROGRAM
);
}
#[test]
fn a_rule_cannot_be_forged_by_struct_literal() {
let rule = AllowRule::Evm {
chain_id: 1,
to: EvmAddress::new(ADDRESS).expect("a valid address"),
selector: Some(Selector::new("0xA9059CBB").expect("a valid selector")),
max_value: Some(1_000),
};
assert_eq!(
rule.condition(),
concat!(
"eth.tx.to == '0x000000000000000000000000000000000000aaaa'",
" && eth.tx.chain_id == 1",
" && eth.tx.data[0..10] == '0xa9059cbb'",
" && eth.tx.value <= 1000"
)
);
for bad in ["' OR '1'=='1", "0xAAAA"] {
assert!(EvmAddress::new(bad).is_err(), "`{bad}` must not pass");
assert!(Selector::new(bad).is_err(), "`{bad}` must not pass");
assert!(ProgramKey::new(bad).is_err(), "`{bad}` must not pass");
}
}
#[test]
fn a_cap_past_the_policy_engines_int_width_is_refused() {
let err = AllowRule::evm(1, ADDRESS, None, Some(u128::MAX))
.expect_err("i128 is the engine's width");
assert!(matches!(err, SignerError::Invalid(_)), "got: {err:?}");
}
}