fix(evm::engine): added shared settings check in vet_transaction

This commit is contained in:
hdbg
2026-03-10 19:57:30 +01:00
parent b7c4f2e735
commit b3e378b5fc
5 changed files with 100 additions and 31 deletions

View File

@@ -50,19 +50,19 @@ message SharedSettings {
uint64 chain_id = 2; uint64 chain_id = 2;
optional google.protobuf.Timestamp valid_from = 3; optional google.protobuf.Timestamp valid_from = 3;
optional google.protobuf.Timestamp valid_until = 4; 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 bytes max_priority_fee_per_gas = 6; // U256 as big-endian bytes
optional TransactionRateLimit rate_limit = 7; optional TransactionRateLimit rate_limit = 7;
} }
message EtherTransferSettings { 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; VolumeRateLimit limit = 2;
} }
message TokenTransferSettings { message TokenTransferSettings {
bytes token_contract = 1; // 20-byte Ethereum address bytes token_contract = 1; // 20-byte Ethereum address
optional bytes target = 2; // 20-byte Ethereum address; absent means any recipient allowed optional bytes target = 2; // 20-byte Ethereum address; absent means any recipient allowed
repeated VolumeRateLimit volume_limits = 3; repeated VolumeRateLimit volume_limits = 3;
} }
@@ -74,20 +74,20 @@ message SpecificGrant {
} }
message EtherTransferMeaning { message EtherTransferMeaning {
bytes to = 1; // 20-byte Ethereum address bytes to = 1; // 20-byte Ethereum address
bytes value = 2; // U256 as big-endian bytes bytes value = 2; // U256 as big-endian bytes
} }
message TokenInfo { message TokenInfo {
string symbol = 1; string symbol = 1;
bytes address = 2; // 20-byte Ethereum address bytes address = 2; // 20-byte Ethereum address
uint64 chain_id = 3; uint64 chain_id = 3;
} }
// Mirror of token_transfers::Meaning // Mirror of token_transfers::Meaning
message TokenTransferMeaning { message TokenTransferMeaning {
TokenInfo token = 1; 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 bytes value = 3; // U256 as big-endian bytes
} }
@@ -101,13 +101,13 @@ message SpecificMeaning {
// --- Eval error types --- // --- Eval error types ---
message GasLimitExceededViolation { 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 optional bytes max_priority_fee_per_gas = 2; // U256 as big-endian bytes
} }
message EvalViolation { message EvalViolation {
oneof kind { oneof kind {
bytes invalid_target = 1; // 20-byte Ethereum address bytes invalid_target = 1; // 20-byte Ethereum address
GasLimitExceededViolation gas_limit_exceeded = 2; GasLimitExceededViolation gas_limit_exceeded = 2;
google.protobuf.Empty rate_limit_exceeded = 3; google.protobuf.Empty rate_limit_exceeded = 3;
google.protobuf.Empty volumetric_limit_exceeded = 4; google.protobuf.Empty volumetric_limit_exceeded = 4;
@@ -167,6 +167,7 @@ message GrantEntry {
int32 id = 1; int32 id = 1;
int32 client_id = 2; int32 client_id = 2;
SharedSettings shared = 3; SharedSettings shared = 3;
SpecificGrant specific = 4;
} }
message EvmGrantListRequest { message EvmGrantListRequest {
@@ -187,7 +188,7 @@ message EvmGrantList {
// --- Client transaction operations --- // --- Client transaction operations ---
message EvmSignTransactionRequest { 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) 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 // is always either an eval error or an internal error, never a partial success
message EvmSignTransactionResponse { message EvmSignTransactionResponse {
oneof result { 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; TransactionEvalError eval_error = 2;
EvmError error = 3; EvmError error = 3;
} }
} }
message EvmAnalyzeTransactionRequest { 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 bytes rlp_transaction = 2; // RLP-encoded EIP-1559 transaction
} }

View File

@@ -1,9 +1,9 @@
pub mod abi; pub mod abi;
pub mod safe_signer; pub mod safe_signer;
use alloy::{consensus::TxEip1559, primitives::TxKind}; use alloy::{consensus::TxEip1559, primitives::{TxKind, U256}};
use chrono::Utc; use chrono::Utc;
use diesel::{QueryResult, insert_into}; use diesel::{QueryResult, insert_into, sqlite::Sqlite};
use diesel_async::{AsyncConnection, RunQueryDsl}; use diesel_async::{AsyncConnection, RunQueryDsl};
use crate::{ use crate::{
@@ -16,7 +16,8 @@ use crate::{
schema::{self, evm_transaction_log}, schema::{self, evm_transaction_log},
}, },
evm::policies::{ evm::policies::{
EvalContext, EvalViolation, FullGrant, Grant, Policy, SpecificGrant, SpecificMeaning, DatabaseID, EvalContext, EvalViolation, FullGrant, Grant, Policy, SharedGrantSettings,
SpecificGrant, SpecificMeaning,
ether_transfer::EtherTransfer, token_transfers::TokenTransfer, ether_transfer::EtherTransfer, token_transfers::TokenTransfer,
}, },
}; };
@@ -107,6 +108,54 @@ pub enum RunKind {
CheckOnly, CheckOnly,
} }
async fn check_shared_constraints(
context: &EvalContext,
shared: &SharedGrantSettings,
shared_grant_id: DatabaseID,
conn: &mut impl AsyncConnection<Backend = Sqlite>,
) -> QueryResult<Vec<EvalViolation>> {
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 // Supporting only EIP-1559 transactions for now, but we can easily extend this to support legacy transactions if needed
pub struct Engine { pub struct Engine {
db: db::DatabasePool, db: db::DatabasePool,
@@ -125,7 +174,11 @@ impl Engine {
.await? .await?
.ok_or(PolicyError::NoMatchingGrant)?; .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() { if !violations.is_empty() {
return Err(PolicyError::Violations(violations)); return Err(PolicyError::Violations(violations));
} else if run_kind == RunKind::Execution { } else if run_kind == RunKind::Execution {
@@ -247,9 +300,11 @@ impl Engine {
wallet_id, wallet_id,
client_id, client_id,
chain: transaction.chain_id, chain: transaction.chain_id,
to: to, to,
value: transaction.value, value: transaction.value,
calldata: transaction.input.clone(), 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) { if let Some(meaning) = EtherTransfer::analyze(&context) {

View File

@@ -27,6 +27,10 @@ pub struct EvalContext {
pub to: Address, pub to: Address,
pub value: U256, pub value: U256,
pub calldata: Bytes, pub calldata: Bytes,
// Gas pricing (EIP-1559)
pub max_fee_per_gas: u128,
pub max_priority_fee_per_gas: u128,
} }
#[derive(Debug, Error, Diagnostic)] #[derive(Debug, Error, Diagnostic)]

View File

@@ -201,6 +201,7 @@ impl Policy for EtherTransfer {
.filter(evm_basic_grant::wallet_id.eq(context.wallet_id)) .filter(evm_basic_grant::wallet_id.eq(context.wallet_id))
.filter(evm_basic_grant::client_id.eq(context.client_id)) .filter(evm_basic_grant::client_id.eq(context.client_id))
.filter(evm_ether_transfer_grant_target::address.eq(&target_bytes)) .filter(evm_ether_transfer_grant_target::address.eq(&target_bytes))
.filter(evm_basic_grant::revoked_at.is_null())
.select(( .select((
EvmBasicGrant::as_select(), EvmBasicGrant::as_select(),
EvmEtherTransferGrant::as_select(), EvmEtherTransferGrant::as_select(),

View File

@@ -21,7 +21,9 @@ use crate::db::schema::{
}; };
use crate::evm::{ use crate::evm::{
abi::IERC20::transferCall, abi::IERC20::transferCall,
policies::{Grant, Policy, SharedGrantSettings, SpecificGrant, SpecificMeaning, VolumeRateLimit}, policies::{
Grant, Policy, SharedGrantSettings, SpecificGrant, SpecificMeaning, VolumeRateLimit,
},
utils, utils,
}; };
@@ -30,8 +32,7 @@ use super::{DatabaseID, EvalContext, EvalViolation};
#[diesel::auto_type] #[diesel::auto_type]
fn grant_join() -> _ { fn grant_join() -> _ {
evm_token_transfer_grant::table.inner_join( evm_token_transfer_grant::table.inner_join(
evm_basic_grant::table evm_basic_grant::table.on(evm_token_transfer_grant::basic_grant_id.eq(evm_basic_grant::id)),
.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 token_contract_bytes = context.to.to_vec();
let grant: Option<(EvmBasicGrant, EvmTokenTransferGrant)> = grant_join() 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::wallet_id.eq(context.wallet_id))
.filter(evm_basic_grant::client_id.eq(context.client_id)) .filter(evm_basic_grant::client_id.eq(context.client_id))
.filter(evm_token_transfer_grant::token_contract.eq(&token_contract_bytes)) .filter(evm_token_transfer_grant::token_contract.eq(&token_contract_bytes))
@@ -299,7 +301,10 @@ impl Policy for TokenTransfer {
) -> QueryResult<Vec<Grant<Self::Settings>>> { ) -> QueryResult<Vec<Grant<Self::Settings>>> {
let grants: Vec<(EvmBasicGrant, EvmTokenTransferGrant)> = grant_join() let grants: Vec<(EvmBasicGrant, EvmTokenTransferGrant)> = grant_join()
.filter(evm_basic_grant::revoked_at.is_null()) .filter(evm_basic_grant::revoked_at.is_null())
.select((EvmBasicGrant::as_select(), EvmTokenTransferGrant::as_select())) .select((
EvmBasicGrant::as_select(),
EvmTokenTransferGrant::as_select(),
))
.load(conn) .load(conn)
.await?; .await?;
@@ -318,7 +323,10 @@ impl Policy for TokenTransfer {
let mut limits_by_grant: HashMap<i32, Vec<EvmTokenTransferVolumeLimit>> = HashMap::new(); let mut limits_by_grant: HashMap<i32, Vec<EvmTokenTransferVolumeLimit>> = HashMap::new();
for limit in all_volume_limits { 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 grants
@@ -331,20 +339,20 @@ impl Policy for TokenTransfer {
.iter() .iter()
.map(|row| { .map(|row| {
Ok(VolumeRateLimit { Ok(VolumeRateLimit {
max_volume: utils::try_bytes_to_u256(&row.max_volume) max_volume: utils::try_bytes_to_u256(&row.max_volume).map_err(|e| {
.map_err(|e| diesel::result::Error::DeserializationError(Box::new(e)))?, diesel::result::Error::DeserializationError(Box::new(e))
})?,
window: Duration::seconds(row.window_secs as i64), window: Duration::seconds(row.window_secs as i64),
}) })
}) })
.collect::<QueryResult<Vec<_>>>()?; .collect::<QueryResult<Vec<_>>>()?;
let token_contract: [u8; 20] = specific let token_contract: [u8; 20] =
.token_contract specific.token_contract.clone().try_into().map_err(|_| {
.clone() diesel::result::Error::DeserializationError(
.try_into() "Invalid token contract address length".into(),
.map_err(|_| diesel::result::Error::DeserializationError( )
"Invalid token contract address length".into(), })?;
))?;
let target: Option<Address> = match &specific.receiver { let target: Option<Address> = match &specific.receiver {
None => None, None => None,