diff --git a/protobufs/evm.proto b/protobufs/evm.proto index 0f8a0ee..b6469dd 100644 --- a/protobufs/evm.proto +++ b/protobufs/evm.proto @@ -50,19 +50,19 @@ message SharedSettings { uint64 chain_id = 2; optional google.protobuf.Timestamp valid_from = 3; optional google.protobuf.Timestamp valid_until = 4; - optional bytes max_gas_fee_per_gas = 5; // U256 as big-endian bytes + optional bytes max_gas_fee_per_gas = 5; // U256 as big-endian bytes optional bytes max_priority_fee_per_gas = 6; // U256 as big-endian bytes optional TransactionRateLimit rate_limit = 7; } message EtherTransferSettings { - repeated bytes targets = 1; // list of 20-byte Ethereum addresses + repeated bytes targets = 1; // list of 20-byte Ethereum addresses VolumeRateLimit limit = 2; } message TokenTransferSettings { - bytes token_contract = 1; // 20-byte Ethereum address - optional bytes target = 2; // 20-byte Ethereum address; absent means any recipient allowed + bytes token_contract = 1; // 20-byte Ethereum address + optional bytes target = 2; // 20-byte Ethereum address; absent means any recipient allowed repeated VolumeRateLimit volume_limits = 3; } @@ -74,20 +74,20 @@ message SpecificGrant { } message EtherTransferMeaning { - bytes to = 1; // 20-byte Ethereum address + bytes to = 1; // 20-byte Ethereum address bytes value = 2; // U256 as big-endian bytes } message TokenInfo { string symbol = 1; - bytes address = 2; // 20-byte Ethereum address + bytes address = 2; // 20-byte Ethereum address uint64 chain_id = 3; } // Mirror of token_transfers::Meaning message TokenTransferMeaning { TokenInfo token = 1; - bytes to = 2; // 20-byte Ethereum address + bytes to = 2; // 20-byte Ethereum address bytes value = 3; // U256 as big-endian bytes } @@ -101,13 +101,13 @@ message SpecificMeaning { // --- Eval error types --- message GasLimitExceededViolation { - optional bytes max_gas_fee_per_gas = 1; // U256 as big-endian bytes + optional bytes max_gas_fee_per_gas = 1; // U256 as big-endian bytes optional bytes max_priority_fee_per_gas = 2; // U256 as big-endian bytes } message EvalViolation { oneof kind { - bytes invalid_target = 1; // 20-byte Ethereum address + bytes invalid_target = 1; // 20-byte Ethereum address GasLimitExceededViolation gas_limit_exceeded = 2; google.protobuf.Empty rate_limit_exceeded = 3; google.protobuf.Empty volumetric_limit_exceeded = 4; @@ -167,6 +167,7 @@ message GrantEntry { int32 id = 1; int32 client_id = 2; SharedSettings shared = 3; + SpecificGrant specific = 4; } message EvmGrantListRequest { @@ -187,7 +188,7 @@ message EvmGrantList { // --- Client transaction operations --- message EvmSignTransactionRequest { - bytes wallet_address = 1; // 20-byte Ethereum address + bytes wallet_address = 1; // 20-byte Ethereum address bytes rlp_transaction = 2; // RLP-encoded EIP-1559 transaction (unsigned) } @@ -195,14 +196,14 @@ message EvmSignTransactionRequest { // is always either an eval error or an internal error, never a partial success message EvmSignTransactionResponse { oneof result { - bytes signature = 1; // 65-byte signature: r[32] || s[32] || v[1] + bytes signature = 1; // 65-byte signature: r[32] || s[32] || v[1] TransactionEvalError eval_error = 2; EvmError error = 3; } } message EvmAnalyzeTransactionRequest { - bytes wallet_address = 1; // 20-byte Ethereum address + bytes wallet_address = 1; // 20-byte Ethereum address bytes rlp_transaction = 2; // RLP-encoded EIP-1559 transaction } diff --git a/server/crates/arbiter-server/src/evm/mod.rs b/server/crates/arbiter-server/src/evm/mod.rs index b0262b9..7a9432b 100644 --- a/server/crates/arbiter-server/src/evm/mod.rs +++ b/server/crates/arbiter-server/src/evm/mod.rs @@ -1,9 +1,9 @@ pub mod abi; pub mod safe_signer; -use alloy::{consensus::TxEip1559, primitives::TxKind}; +use alloy::{consensus::TxEip1559, primitives::{TxKind, U256}}; use chrono::Utc; -use diesel::{QueryResult, insert_into}; +use diesel::{QueryResult, insert_into, sqlite::Sqlite}; use diesel_async::{AsyncConnection, RunQueryDsl}; use crate::{ @@ -16,7 +16,8 @@ use crate::{ schema::{self, evm_transaction_log}, }, evm::policies::{ - EvalContext, EvalViolation, FullGrant, Grant, Policy, SpecificGrant, SpecificMeaning, + DatabaseID, EvalContext, EvalViolation, FullGrant, Grant, Policy, SharedGrantSettings, + SpecificGrant, SpecificMeaning, ether_transfer::EtherTransfer, token_transfers::TokenTransfer, }, }; @@ -107,6 +108,54 @@ pub enum RunKind { CheckOnly, } +async fn check_shared_constraints( + context: &EvalContext, + shared: &SharedGrantSettings, + shared_grant_id: DatabaseID, + conn: &mut impl AsyncConnection, +) -> QueryResult> { + let mut violations = Vec::new(); + let now = Utc::now(); + + // Validity window + if shared.valid_from.map_or(false, |t| now < t) + || shared.valid_until.map_or(false, |t| now > t) + { + violations.push(EvalViolation::InvalidTime); + } + + // Gas fee caps + let fee_exceeded = shared + .max_gas_fee_per_gas + .map_or(false, |cap| U256::from(context.max_fee_per_gas) > cap); + let priority_exceeded = shared + .max_priority_fee_per_gas + .map_or(false, |cap| U256::from(context.max_priority_fee_per_gas) > cap); + if fee_exceeded || priority_exceeded { + violations.push(EvalViolation::GasLimitExceeded { + max_gas_fee_per_gas: shared.max_gas_fee_per_gas, + max_priority_fee_per_gas: shared.max_priority_fee_per_gas, + }); + } + + // Transaction count rate limit + if let Some(rate_limit) = &shared.rate_limit { + let window_start = SqliteTimestamp(now - rate_limit.window); + let count: i64 = evm_transaction_log::table + .filter(evm_transaction_log::grant_id.eq(shared_grant_id)) + .filter(evm_transaction_log::signed_at.ge(window_start)) + .count() + .get_result(conn) + .await?; + + if count >= rate_limit.count as i64 { + violations.push(EvalViolation::RateLimitExceeded); + } + } + + Ok(violations) +} + // Supporting only EIP-1559 transactions for now, but we can easily extend this to support legacy transactions if needed pub struct Engine { db: db::DatabasePool, @@ -125,7 +174,11 @@ impl Engine { .await? .ok_or(PolicyError::NoMatchingGrant)?; - let violations = P::evaluate(&context, meaning, &grant, &mut conn).await?; + let mut violations = + check_shared_constraints(&context, &grant.shared, grant.shared_grant_id, &mut conn) + .await?; + violations.extend(P::evaluate(&context, meaning, &grant, &mut conn).await?); + if !violations.is_empty() { return Err(PolicyError::Violations(violations)); } else if run_kind == RunKind::Execution { @@ -247,9 +300,11 @@ impl Engine { wallet_id, client_id, chain: transaction.chain_id, - to: to, + to, value: transaction.value, calldata: transaction.input.clone(), + max_fee_per_gas: transaction.max_fee_per_gas, + max_priority_fee_per_gas: transaction.max_priority_fee_per_gas, }; if let Some(meaning) = EtherTransfer::analyze(&context) { diff --git a/server/crates/arbiter-server/src/evm/policies.rs b/server/crates/arbiter-server/src/evm/policies.rs index 086de24..4e5524c 100644 --- a/server/crates/arbiter-server/src/evm/policies.rs +++ b/server/crates/arbiter-server/src/evm/policies.rs @@ -27,6 +27,10 @@ pub struct EvalContext { pub to: Address, pub value: U256, pub calldata: Bytes, + + // Gas pricing (EIP-1559) + pub max_fee_per_gas: u128, + pub max_priority_fee_per_gas: u128, } #[derive(Debug, Error, Diagnostic)] diff --git a/server/crates/arbiter-server/src/evm/policies/ether_transfer.rs b/server/crates/arbiter-server/src/evm/policies/ether_transfer.rs index 1333932..dda665a 100644 --- a/server/crates/arbiter-server/src/evm/policies/ether_transfer.rs +++ b/server/crates/arbiter-server/src/evm/policies/ether_transfer.rs @@ -201,6 +201,7 @@ impl Policy for EtherTransfer { .filter(evm_basic_grant::wallet_id.eq(context.wallet_id)) .filter(evm_basic_grant::client_id.eq(context.client_id)) .filter(evm_ether_transfer_grant_target::address.eq(&target_bytes)) + .filter(evm_basic_grant::revoked_at.is_null()) .select(( EvmBasicGrant::as_select(), EvmEtherTransferGrant::as_select(), diff --git a/server/crates/arbiter-server/src/evm/policies/token_transfers.rs b/server/crates/arbiter-server/src/evm/policies/token_transfers.rs index 9ef3dc0..9991553 100644 --- a/server/crates/arbiter-server/src/evm/policies/token_transfers.rs +++ b/server/crates/arbiter-server/src/evm/policies/token_transfers.rs @@ -21,7 +21,9 @@ use crate::db::schema::{ }; use crate::evm::{ abi::IERC20::transferCall, - policies::{Grant, Policy, SharedGrantSettings, SpecificGrant, SpecificMeaning, VolumeRateLimit}, + policies::{ + Grant, Policy, SharedGrantSettings, SpecificGrant, SpecificMeaning, VolumeRateLimit, + }, utils, }; @@ -30,8 +32,7 @@ use super::{DatabaseID, EvalContext, EvalViolation}; #[diesel::auto_type] fn grant_join() -> _ { evm_token_transfer_grant::table.inner_join( - evm_basic_grant::table - .on(evm_token_transfer_grant::basic_grant_id.eq(evm_basic_grant::id)), + evm_basic_grant::table.on(evm_token_transfer_grant::basic_grant_id.eq(evm_basic_grant::id)), ) } @@ -206,6 +207,7 @@ impl Policy for TokenTransfer { let token_contract_bytes = context.to.to_vec(); let grant: Option<(EvmBasicGrant, EvmTokenTransferGrant)> = grant_join() + .filter(evm_basic_grant::revoked_at.is_null()) .filter(evm_basic_grant::wallet_id.eq(context.wallet_id)) .filter(evm_basic_grant::client_id.eq(context.client_id)) .filter(evm_token_transfer_grant::token_contract.eq(&token_contract_bytes)) @@ -299,7 +301,10 @@ impl Policy for TokenTransfer { ) -> QueryResult>> { let grants: Vec<(EvmBasicGrant, EvmTokenTransferGrant)> = grant_join() .filter(evm_basic_grant::revoked_at.is_null()) - .select((EvmBasicGrant::as_select(), EvmTokenTransferGrant::as_select())) + .select(( + EvmBasicGrant::as_select(), + EvmTokenTransferGrant::as_select(), + )) .load(conn) .await?; @@ -318,7 +323,10 @@ impl Policy for TokenTransfer { let mut limits_by_grant: HashMap> = HashMap::new(); for limit in all_volume_limits { - limits_by_grant.entry(limit.grant_id).or_default().push(limit); + limits_by_grant + .entry(limit.grant_id) + .or_default() + .push(limit); } grants @@ -331,20 +339,20 @@ impl Policy for TokenTransfer { .iter() .map(|row| { Ok(VolumeRateLimit { - max_volume: utils::try_bytes_to_u256(&row.max_volume) - .map_err(|e| diesel::result::Error::DeserializationError(Box::new(e)))?, + max_volume: utils::try_bytes_to_u256(&row.max_volume).map_err(|e| { + diesel::result::Error::DeserializationError(Box::new(e)) + })?, window: Duration::seconds(row.window_secs as i64), }) }) .collect::>>()?; - let token_contract: [u8; 20] = specific - .token_contract - .clone() - .try_into() - .map_err(|_| diesel::result::Error::DeserializationError( - "Invalid token contract address length".into(), - ))?; + let token_contract: [u8; 20] = + specific.token_contract.clone().try_into().map_err(|_| { + diesel::result::Error::DeserializationError( + "Invalid token contract address length".into(), + ) + })?; let target: Option
= match &specific.receiver { None => None,