Merge branch 'main' into security-hash-revoke_at
Some checks failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful

This commit is contained in:
CleverWild
2026-06-09 20:58:07 +02:00
233 changed files with 16624 additions and 4885 deletions

View File

@@ -45,7 +45,7 @@ sol! {
sol! {
/// Permit2 — Uniswap's canonical token approval manager.
/// Replaces per-contract ERC-20 approve() with a single approval hub.
/// Replaces per-contract ERC-20 `approve()` with a single approval hub.
#[derive(Debug)]
interface IPermit2 {
struct TokenPermissions {

View File

@@ -1,21 +1,8 @@
pub mod abi;
pub mod safe_signer;
use alloy::primitives::Address;
use alloy::{
consensus::TxEip1559,
primitives::{TxKind, U256},
};
use chrono::Utc;
use diesel::{
ExpressionMethods as _, OptionalExtension, QueryDsl as _, QueryResult, SelectableHelper,
insert_into, sqlite::Sqlite, update,
};
use diesel_async::{AsyncConnection, RunQueryDsl};
use kameo::actor::ActorRef;
use crate::{
actors::keyholder::KeyHolder,
actors::vault::Vault,
crypto::integrity,
db::{
self, DatabaseError,
@@ -33,6 +20,19 @@ use crate::{
},
};
use alloy::{
consensus::TxEip1559,
primitives::{Address, TxKind, U256},
};
use chrono::Utc;
use diesel::{
ExpressionMethods as _, OptionalExtension, QueryDsl as _, QueryResult, SelectableHelper,
insert_into, sqlite::Sqlite, update,
};
pub mod abi;
pub mod safe_signer;
pub mod policies;
mod utils;
@@ -40,7 +40,7 @@ mod utils;
#[derive(Debug, thiserror::Error)]
pub enum PolicyError {
#[error("Database error")]
Database(#[from] crate::db::DatabaseError),
Database(#[from] DatabaseError),
#[error("Transaction violates policy: {0:?}")]
Violations(Vec<EvalViolation>),
#[error("No matching grant found")]
@@ -72,7 +72,7 @@ pub enum AnalyzeError {
#[derive(Debug, thiserror::Error)]
pub enum ListError {
#[error("Database error")]
Database(#[from] crate::db::DatabaseError),
Database(#[from] DatabaseError),
#[error("Integrity verification failed for grant")]
Integrity(#[from] integrity::Error),
@@ -133,7 +133,7 @@ async fn check_shared_constraints(
.get_result(conn)
.await?;
if count >= rate_limit.count as i64 {
if count >= rate_limit.count.into() {
violations.push(EvalViolation::RateLimitExceeded);
}
}
@@ -144,7 +144,7 @@ async fn check_shared_constraints(
// 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,
keyholder: ActorRef<KeyHolder>,
vault: ActorRef<Vault>,
}
impl Engine {
@@ -164,7 +164,7 @@ impl Engine {
.map_err(DatabaseError::from)?
.ok_or(PolicyError::NoMatchingGrant)?;
integrity::verify_entity(&mut conn, &self.keyholder, &grant.settings, grant.id).await?;
integrity::verify_entity(&mut conn, &self.vault, &grant.settings, grant.id).await?;
let mut violations = check_shared_constraints(
&context,
@@ -185,24 +185,22 @@ impl Engine {
}
if run_kind == RunKind::Execution {
conn.transaction(|conn| {
Box::pin(async move {
let log_id: i32 = insert_into(evm_transaction_log::table)
.values(&NewEvmTransactionLog {
grant_id: grant.common_settings_id,
wallet_access_id: context.target.id,
chain_id: context.chain as i32,
eth_value: utils::u256_to_bytes(context.value).to_vec(),
signed_at: Utc::now().into(),
})
.returning(evm_transaction_log::id)
.get_result(conn)
.await?;
conn.transaction(async |conn| {
let log_id: i32 = insert_into(evm_transaction_log::table)
.values(&NewEvmTransactionLog {
grant_id: grant.common_settings_id,
wallet_access_id: context.target.id,
chain_id: context.chain.into(),
eth_value: utils::u256_to_bytes(context.value).to_vec(),
signed_at: Utc::now().into(),
})
.returning(evm_transaction_log::id)
.get_result(&mut *conn)
.await?;
P::record_transaction(&context, meaning, log_id, &grant, conn).await?;
P::record_transaction(&context, meaning, log_id, &grant, &mut *conn).await?;
QueryResult::Ok(())
})
QueryResult::Ok(())
})
.await
.map_err(DatabaseError::from)?;
@@ -213,8 +211,8 @@ impl Engine {
}
impl Engine {
pub fn new(db: db::DatabasePool, keyholder: ActorRef<KeyHolder>) -> Self {
Self { db, keyholder }
pub const fn new(db: db::DatabasePool, vault: ActorRef<Vault>) -> Self {
Self { db, vault }
}
pub async fn create_grant<P: Policy>(
@@ -225,51 +223,55 @@ impl Engine {
P::Settings: Clone,
{
let mut conn = self.db.get().await?;
let keyholder = self.keyholder.clone();
let vault = self.vault.clone();
let id = conn
.transaction(|conn| {
Box::pin(async move {
use schema::evm_basic_grant;
.transaction(async |conn| {
use schema::evm_basic_grant;
let basic_grant: EvmBasicGrant = insert_into(evm_basic_grant::table)
.values(&NewEvmBasicGrant {
chain_id: full_grant.shared.chain as i32,
wallet_access_id: full_grant.shared.wallet_access_id,
valid_from: full_grant.shared.valid_from.map(SqliteTimestamp),
valid_until: full_grant.shared.valid_until.map(SqliteTimestamp),
max_gas_fee_per_gas: full_grant
.shared
.max_gas_fee_per_gas
.map(|fee| utils::u256_to_bytes(fee).to_vec()),
max_priority_fee_per_gas: full_grant
.shared
.max_priority_fee_per_gas
.map(|fee| utils::u256_to_bytes(fee).to_vec()),
rate_limit_count: full_grant
.shared
.rate_limit
.as_ref()
.map(|rl| rl.count as i32),
rate_limit_window_secs: full_grant
.shared
.rate_limit
.as_ref()
.map(|rl| rl.window.num_seconds() as i32),
revoked_at: None,
})
.returning(evm_basic_grant::all_columns)
.get_result(conn)
.await?;
#[expect(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::as_conversions,
reason = "fixme! #86"
)]
let basic_grant: EvmBasicGrant = insert_into(evm_basic_grant::table)
.values(&NewEvmBasicGrant {
chain_id: full_grant.shared.chain.into(),
wallet_access_id: full_grant.shared.wallet_access_id,
valid_from: full_grant.shared.valid_from.map(SqliteTimestamp),
valid_until: full_grant.shared.valid_until.map(SqliteTimestamp),
max_gas_fee_per_gas: full_grant
.shared
.max_gas_fee_per_gas
.map(|fee| utils::u256_to_bytes(fee).to_vec()),
max_priority_fee_per_gas: full_grant
.shared
.max_priority_fee_per_gas
.map(|fee| utils::u256_to_bytes(fee).to_vec()),
rate_limit_count: full_grant
.shared
.rate_limit
.as_ref()
.map(|rl| rl.count as i32),
rate_limit_window_secs: full_grant
.shared
.rate_limit
.as_ref()
.map(|rl| rl.window.num_seconds() as i32),
revoked_at: None,
})
.returning(evm_basic_grant::all_columns)
.get_result(&mut *conn)
.await?;
P::create_grant(&basic_grant, &full_grant.specific, conn).await?;
P::create_grant(&basic_grant, &full_grant.specific, &mut *conn).await?;
integrity::sign_entity(conn, &keyholder, &full_grant, basic_grant.id)
.await
.map_err(|_| diesel::result::Error::RollbackTransaction)?;
integrity::sign_entity(&mut *conn, &vault, &full_grant, basic_grant.id)
.await
.map_err(|_| diesel::result::Error::RollbackTransaction)?;
QueryResult::Ok(basic_grant.id)
})
QueryResult::Ok(basic_grant.id)
})
.await?;
@@ -281,143 +283,141 @@ impl Engine {
basic_grant_id: i32,
) -> Result<(), DatabaseError> {
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
let keyholder = self.keyholder.clone();
let vault = self.vault.clone();
conn.transaction(|conn| {
Box::pin(async move {
use crate::db::schema::{
evm_basic_grant, evm_ether_transfer_grant, evm_ether_transfer_grant_target,
evm_ether_transfer_limit, evm_token_transfer_grant,
evm_token_transfer_volume_limit,
conn.transaction(async move |conn| {
use crate::db::schema::{
evm_basic_grant, evm_ether_transfer_grant, evm_ether_transfer_grant_target,
evm_ether_transfer_limit, evm_token_transfer_grant,
evm_token_transfer_volume_limit,
};
update(evm_basic_grant::table)
.filter(evm_basic_grant::id.eq(basic_grant_id))
.set(evm_basic_grant::revoked_at.eq(SqliteTimestamp(Utc::now())))
.execute(&mut *conn)
.await?;
let basic_grant: EvmBasicGrant = evm_basic_grant::table
.filter(evm_basic_grant::id.eq(basic_grant_id))
.select(EvmBasicGrant::as_select())
.first(&mut *conn)
.await?;
let shared = SharedGrantSettings::try_from_model(basic_grant)?;
if let Some(ether_grant) = evm_ether_transfer_grant::table
.filter(evm_ether_transfer_grant::basic_grant_id.eq(basic_grant_id))
.select(EvmEtherTransferGrant::as_select())
.first(&mut *conn)
.await
.optional()?
{
let target_rows: Vec<EvmEtherTransferGrantTarget> =
evm_ether_transfer_grant_target::table
.filter(evm_ether_transfer_grant_target::grant_id.eq(ether_grant.id))
.select(EvmEtherTransferGrantTarget::as_select())
.load(&mut *conn)
.await?;
let targets: Vec<Address> = target_rows
.into_iter()
.filter_map(|target| {
let arr: [u8; 20] = target.address.try_into().ok()?;
Some(Address::from(arr))
})
.collect();
let limit: EvmEtherTransferLimit = evm_ether_transfer_limit::table
.filter(evm_ether_transfer_limit::id.eq(ether_grant.limit_id))
.select(EvmEtherTransferLimit::as_select())
.first(&mut *conn)
.await?;
let settings = CombinedSettings {
shared: shared.clone(),
specific: policies::ether_transfer::Settings {
target: targets,
limit: VolumeRateLimit {
max_volume: utils::try_bytes_to_u256(&limit.max_volume).map_err(
|err| {
diesel::result::Error::DeserializationError(Box::new(err))
},
)?,
window: chrono::Duration::seconds(limit.window_secs.into()),
},
},
};
update(evm_basic_grant::table)
.filter(evm_basic_grant::id.eq(basic_grant_id))
.set(evm_basic_grant::revoked_at.eq(SqliteTimestamp(Utc::now())))
.execute(conn)
.await?;
let basic_grant: EvmBasicGrant = evm_basic_grant::table
.filter(evm_basic_grant::id.eq(basic_grant_id))
.select(EvmBasicGrant::as_select())
.first(conn)
.await?;
let shared = SharedGrantSettings::try_from_model(basic_grant)?;
if let Some(ether_grant) = evm_ether_transfer_grant::table
.filter(evm_ether_transfer_grant::basic_grant_id.eq(basic_grant_id))
.select(EvmEtherTransferGrant::as_select())
.first(conn)
integrity::sign_entity(&mut *conn, &vault, &settings, basic_grant_id)
.await
.optional()?
{
let target_rows: Vec<EvmEtherTransferGrantTarget> =
evm_ether_transfer_grant_target::table
.filter(evm_ether_transfer_grant_target::grant_id.eq(ether_grant.id))
.select(EvmEtherTransferGrantTarget::as_select())
.load(conn)
.await?;
let targets: Vec<Address> = target_rows
.into_iter()
.filter_map(|target| {
let arr: [u8; 20] = target.address.try_into().ok()?;
Some(Address::from(arr))
})
.collect();
.map_err(|_| diesel::result::Error::RollbackTransaction)?;
let limit: EvmEtherTransferLimit = evm_ether_transfer_limit::table
.filter(evm_ether_transfer_limit::id.eq(ether_grant.limit_id))
.select(EvmEtherTransferLimit::as_select())
.first(conn)
return QueryResult::Ok(());
}
if let Some(token_grant) = evm_token_transfer_grant::table
.filter(evm_token_transfer_grant::basic_grant_id.eq(basic_grant_id))
.select(EvmTokenTransferGrant::as_select())
.first(&mut *conn)
.await
.optional()?
{
let volume_limit_rows: Vec<EvmTokenTransferVolumeLimit> =
evm_token_transfer_volume_limit::table
.filter(evm_token_transfer_volume_limit::grant_id.eq(token_grant.id))
.select(EvmTokenTransferVolumeLimit::as_select())
.load(&mut *conn)
.await?;
let settings = CombinedSettings {
shared: shared.clone(),
specific: crate::evm::policies::ether_transfer::Settings {
target: targets,
limit: VolumeRateLimit {
max_volume: utils::try_bytes_to_u256(&limit.max_volume).map_err(
|err| {
diesel::result::Error::DeserializationError(Box::new(err))
},
)?,
window: chrono::Duration::seconds(limit.window_secs as i64),
},
},
};
integrity::sign_entity(conn, &keyholder, &settings, basic_grant_id)
.await
.map_err(|_| diesel::result::Error::RollbackTransaction)?;
return QueryResult::Ok(());
}
if let Some(token_grant) = evm_token_transfer_grant::table
.filter(evm_token_transfer_grant::basic_grant_id.eq(basic_grant_id))
.select(EvmTokenTransferGrant::as_select())
.first(conn)
.await
.optional()?
{
let volume_limit_rows: Vec<EvmTokenTransferVolumeLimit> =
evm_token_transfer_volume_limit::table
.filter(evm_token_transfer_volume_limit::grant_id.eq(token_grant.id))
.select(EvmTokenTransferVolumeLimit::as_select())
.load(conn)
.await?;
let volume_limits: Vec<VolumeRateLimit> = volume_limit_rows
.into_iter()
.map(|row| {
Ok(VolumeRateLimit {
max_volume: utils::try_bytes_to_u256(&row.max_volume).map_err(
|err| {
diesel::result::Error::DeserializationError(Box::new(err))
},
)?,
window: chrono::Duration::seconds(row.window_secs as i64),
})
let volume_limits: Vec<VolumeRateLimit> = volume_limit_rows
.into_iter()
.map(|row| {
Ok(VolumeRateLimit {
max_volume: utils::try_bytes_to_u256(&row.max_volume).map_err(
|err| {
diesel::result::Error::DeserializationError(Box::new(err))
},
)?,
window: chrono::Duration::seconds(row.window_secs.into()),
})
.collect::<QueryResult<Vec<_>>>()?;
})
.collect::<QueryResult<Vec<_>>>()?;
let target: Option<Address> = match token_grant.receiver {
None => None,
Some(bytes) => {
let arr: [u8; 20] = bytes.try_into().map_err(|_| {
diesel::result::Error::DeserializationError(
"Invalid receiver address length".into(),
)
})?;
Some(Address::from(arr))
}
};
let token_contract: [u8; 20] =
token_grant.token_contract.clone().try_into().map_err(|_| {
let target: Option<Address> = match token_grant.receiver {
None => None,
Some(bytes) => {
let arr: [u8; 20] = bytes.try_into().map_err(|_| {
diesel::result::Error::DeserializationError(
"Invalid token contract address length".into(),
"Invalid receiver address length".into(),
)
})?;
Some(Address::from(arr))
}
};
let settings = CombinedSettings {
shared,
specific: crate::evm::policies::token_transfers::Settings {
token_contract: Address::from(token_contract),
target,
volume_limits,
},
};
let token_contract: [u8; 20] =
token_grant.token_contract.clone().try_into().map_err(|_| {
diesel::result::Error::DeserializationError(
"Invalid token contract address length".into(),
)
})?;
integrity::sign_entity(conn, &keyholder, &settings, basic_grant_id)
.await
.map_err(|_| diesel::result::Error::RollbackTransaction)?;
let settings = CombinedSettings {
shared,
specific: policies::token_transfers::Settings {
token_contract: Address::from(token_contract),
target,
volume_limits,
},
};
return QueryResult::Ok(());
}
integrity::sign_entity(&mut *conn, &vault, &settings, basic_grant_id)
.await
.map_err(|_| diesel::result::Error::RollbackTransaction)?;
Err(diesel::result::Error::NotFound)
})
return QueryResult::Ok(());
}
Err(diesel::result::Error::NotFound)
})
.await
.map_err(DatabaseError::from)
@@ -436,7 +436,7 @@ impl Engine {
// Verify integrity of all grants before returning any results
for grant in &all_grants {
integrity::verify_entity(conn, &self.keyholder, &grant.settings, grant.id).await?;
integrity::verify_entity(conn, &self.vault, &grant.settings, grant.id).await?;
}
Ok(all_grants.into_iter().map(|g| Grant {
@@ -466,7 +466,7 @@ impl Engine {
let TxKind::Call(to) = transaction.to else {
return Err(VetError::ContractCreationNotSupported);
};
let context = policies::EvalContext {
let context = EvalContext {
target,
chain: transaction.chain_id,
to,
@@ -509,7 +509,7 @@ mod tests {
use kameo::{actor::ActorRef, prelude::Spawn};
use rstest::rstest;
use crate::actors::keyholder::{Bootstrap, KeyHolder};
use crate::actors::{GlobalActors, vault::{Bootstrap, Vault}};
use crate::crypto::integrity;
use crate::db::{
self, DatabaseConnection,
@@ -564,10 +564,16 @@ mod tests {
conn: &mut DatabaseConnection,
shared: &SharedGrantSettings,
) -> EvmBasicGrant {
#[expect(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::as_conversions,
reason = "fixme! #86"
)]
insert_into(evm_basic_grant::table)
.values(NewEvmBasicGrant {
wallet_access_id: shared.wallet_access_id,
chain_id: shared.chain as i32,
chain_id: shared.chain.into(),
valid_from: shared.valid_from.map(SqliteTimestamp),
valid_until: shared.valid_until.map(SqliteTimestamp),
max_gas_fee_per_gas: shared
@@ -731,7 +737,7 @@ mod tests {
.values(NewEvmTransactionLog {
grant_id: basic_grant.id,
wallet_access_id: WALLET_ACCESS_ID,
chain_id: CHAIN_ID as i32,
chain_id: CHAIN_ID.into(),
eth_value: super::utils::u256_to_bytes(U256::ZERO).to_vec(),
signed_at: SqliteTimestamp(Utc::now()),
})
@@ -757,8 +763,12 @@ mod tests {
}
}
async fn bootstrapped_keyholder(db: &db::DatabasePool) -> ActorRef<KeyHolder> {
let actor = KeyHolder::spawn(KeyHolder::new(db.clone()).await.unwrap());
async fn bootstrapped_vault(db: &db::DatabasePool) -> ActorRef<Vault> {
let actor = Vault::spawn(
Vault::new(db.clone(), GlobalActors::spawn_message_bus())
.await
.unwrap(),
);
actor
.ask(Bootstrap {
seal_key_raw: SafeCell::new(b"integrity-test-seal-key".to_vec()),
@@ -774,8 +784,8 @@ mod tests {
use diesel::ExpressionMethods as _;
let db = db::create_test_pool().await;
let keyholder = bootstrapped_keyholder(&db).await;
let engine = super::Engine::new(db.clone(), keyholder.clone());
let vault = bootstrapped_vault(&db).await;
let engine = super::Engine::new(db.clone(), vault.clone());
let full_grant = CombinedSettings {
shared: SharedGrantSettings {
@@ -788,7 +798,7 @@ mod tests {
max_priority_fee_per_gas: None,
rate_limit: None,
},
specific: crate::evm::policies::ether_transfer::Settings {
specific: super::policies::ether_transfer::Settings {
target: vec![RECIPIENT],
limit: VolumeRateLimit {
max_volume: U256::from(100u64),
@@ -828,7 +838,7 @@ mod tests {
max_priority_fee_per_gas: 1,
};
let grant = crate::evm::policies::ether_transfer::EtherTransfer::try_find_grant(
let grant = EtherTransfer::try_find_grant(
&context, &mut conn,
)
.await
@@ -836,11 +846,11 @@ mod tests {
.unwrap();
let result =
integrity::verify_entity(&mut conn, &keyholder, &grant.settings, grant.id).await;
integrity::verify_entity(&mut conn, &vault, &grant.settings, grant.id).await;
assert!(matches!(
result,
Err(crate::crypto::integrity::Error::MacMismatch { .. })
Err(integrity::Error::MacMismatch { .. })
));
}

View File

@@ -1,4 +1,8 @@
use std::fmt::Display;
use crate::{
crypto::integrity::v1::Integrable,
db::models::{EvmBasicGrant, EvmWalletAccess},
evm::utils,
};
use alloy::primitives::{Address, Bytes, ChainId, U256};
use chrono::{DateTime, Duration, Utc};
@@ -6,15 +10,9 @@ use diesel::{
ExpressionMethods as _, QueryDsl, SelectableHelper, result::QueryResult, sqlite::Sqlite,
};
use diesel_async::{AsyncConnection, RunQueryDsl};
use std::fmt::Display;
use thiserror::Error;
use crate::{
crypto::integrity::v1::Integrable,
db::models::{self, EvmBasicGrant, EvmWalletAccess},
evm::utils,
};
pub mod ether_transfer;
pub mod token_transfers;
@@ -87,10 +85,10 @@ pub trait Policy: Sized {
// Create a new grant in the database based on the provided grant details, and return its ID
fn create_grant(
basic: &models::EvmBasicGrant,
basic: &EvmBasicGrant,
grant: &Self::Settings,
conn: &mut impl AsyncConnection<Backend = Sqlite>,
) -> impl std::future::Future<Output = QueryResult<DatabaseID>> + Send;
) -> impl Future<Output = QueryResult<DatabaseID>> + Send;
// Try to find an existing grant that matches the transaction context, and return its details if found
// Additionally, return ID of basic grant for shared-logic checks like rate limits and validity periods
@@ -158,7 +156,7 @@ impl SharedGrantSettings {
pub(crate) fn try_from_model(model: EvmBasicGrant) -> QueryResult<Self> {
Ok(Self {
wallet_access_id: model.wallet_access_id,
chain: model.chain_id as u64, // safe because chain_id is stored as i32 but is guaranteed to be a valid ChainId by the API when creating grants
chain: model.chain_id.into(),
valid_from: model.valid_from.map(Into::into),
valid_until: model.valid_until.map(Into::into),
revoked_at: model.revoked_at.map(Into::into),
@@ -170,10 +168,11 @@ impl SharedGrantSettings {
.max_priority_fee_per_gas
.map(|b| utils::try_bytes_to_u256(&b))
.transpose()?,
#[expect(clippy::cast_sign_loss, clippy::as_conversions, reason = "fixme! #86")]
rate_limit: match (model.rate_limit_count, model.rate_limit_window_secs) {
(Some(count), Some(window_secs)) => Some(TransactionRateLimit {
count: count as u32,
window: Duration::seconds(window_secs as i64),
window: Duration::seconds(window_secs.into()),
}),
_ => None,
},
@@ -183,7 +182,7 @@ impl SharedGrantSettings {
pub async fn query_by_id(
conn: &mut impl AsyncConnection<Backend = Sqlite>,
id: i32,
) -> diesel::result::QueryResult<Self> {
) -> QueryResult<Self> {
use crate::db::schema::evm_basic_grant;
let basic_grant: EvmBasicGrant = evm_basic_grant::table

View File

@@ -1,30 +1,32 @@
use std::collections::HashMap;
use std::fmt::Display;
use alloy::primitives::{Address, U256};
use chrono::{DateTime, Duration, Utc};
use diesel::dsl::{auto_type, insert_into};
use diesel::sqlite::Sqlite;
use diesel::{ExpressionMethods, JoinOnDsl, prelude::*};
use diesel_async::{AsyncConnection, RunQueryDsl};
use crate::crypto::integrity::v1::Integrable;
use crate::db::models::{
EvmBasicGrant, EvmEtherTransferGrant, EvmEtherTransferGrantTarget, EvmEtherTransferLimit,
NewEvmEtherTransferLimit, SqliteTimestamp,
};
use crate::db::schema::{evm_basic_grant, evm_ether_transfer_limit, evm_transaction_log};
use crate::evm::policies::{
CombinedSettings, Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning, VolumeRateLimit,
};
use super::{DatabaseID, EvalContext, EvalViolation};
use crate::{
crypto::integrity::v1::Integrable,
db::models::{
EvmBasicGrant, EvmEtherTransferGrant, EvmEtherTransferGrantTarget, EvmEtherTransferLimit,
NewEvmEtherTransferLimit, SqliteTimestamp,
},
db::schema::{evm_basic_grant, evm_ether_transfer_limit, evm_transaction_log},
db::{
models::{self, NewEvmEtherTransferGrant, NewEvmEtherTransferGrantTarget},
models::{NewEvmEtherTransferGrant, NewEvmEtherTransferGrantTarget},
schema::{evm_ether_transfer_grant, evm_ether_transfer_grant_target},
},
evm::policies::{
CombinedSettings, Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning,
VolumeRateLimit,
},
evm::{policies::Policy, utils},
};
use alloy::primitives::{Address, U256};
use chrono::{DateTime, Duration, Utc};
use diesel::{
dsl::{auto_type, insert_into},
prelude::*,
sqlite::Sqlite,
};
use diesel_async::{AsyncConnection, RunQueryDsl};
use std::{collections::HashMap, fmt::Display};
#[auto_type]
fn grant_join() -> _ {
evm_ether_transfer_grant::table.inner_join(
@@ -32,8 +34,6 @@ fn grant_join() -> _ {
)
}
use super::{DatabaseID, EvalContext, EvalViolation};
// Plain ether transfer
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct Meaning {
@@ -46,8 +46,8 @@ impl Display for Meaning {
}
}
impl From<Meaning> for SpecificMeaning {
fn from(val: Meaning) -> SpecificMeaning {
SpecificMeaning::EtherTransfer(val)
fn from(val: Meaning) -> Self {
Self::EtherTransfer(val)
}
}
@@ -62,8 +62,8 @@ impl Integrable for Settings {
}
impl From<Settings> for SpecificGrant {
fn from(val: Settings) -> SpecificGrant {
SpecificGrant::EtherTransfer(val)
fn from(val: Settings) -> Self {
Self::EtherTransfer(val)
}
}
@@ -74,9 +74,7 @@ async fn query_relevant_past_transaction(
) -> QueryResult<Vec<(U256, DateTime<Utc>)>> {
let past_transactions: Vec<(Vec<u8>, SqliteTimestamp)> = evm_transaction_log::table
.filter(evm_transaction_log::grant_id.eq(grant_id))
.filter(
evm_transaction_log::signed_at.ge(SqliteTimestamp(chrono::Utc::now() - longest_window)),
)
.filter(evm_transaction_log::signed_at.ge(SqliteTimestamp(Utc::now() - longest_window)))
.select((
evm_transaction_log::eth_value,
evm_transaction_log::signed_at,
@@ -103,7 +101,7 @@ async fn check_rate_limits(
let past_transaction = query_relevant_past_transaction(grant.id, window, db).await?;
let window_start = chrono::Utc::now() - grant.settings.specific.limit.window;
let window_start = Utc::now() - grant.settings.specific.limit.window;
let prospective_cumulative_volume: U256 = past_transaction
.iter()
.filter(|(_, timestamp)| timestamp >= &window_start)
@@ -153,10 +151,15 @@ impl Policy for EtherTransfer {
}
async fn create_grant(
basic: &models::EvmBasicGrant,
basic: &EvmBasicGrant,
grant: &Self::Settings,
conn: &mut impl AsyncConnection<Backend = Sqlite>,
) -> diesel::result::QueryResult<DatabaseID> {
) -> QueryResult<DatabaseID> {
#[expect(
clippy::cast_possible_truncation,
clippy::as_conversions,
reason = "fixme! #86"
)]
let limit_id: i32 = insert_into(evm_ether_transfer_limit::table)
.values(NewEvmEtherTransferLimit {
window_secs: grant.limit.window.num_seconds() as i32,
@@ -191,7 +194,7 @@ impl Policy for EtherTransfer {
async fn try_find_grant(
context: &EvalContext,
conn: &mut impl AsyncConnection<Backend = Sqlite>,
) -> diesel::result::QueryResult<Option<Grant<Self::Settings>>> {
) -> QueryResult<Option<Grant<Self::Settings>>> {
let target_bytes = context.to.to_vec();
// Find a grant where:
@@ -245,7 +248,7 @@ impl Policy for EtherTransfer {
limit: VolumeRateLimit {
max_volume: utils::try_bytes_to_u256(&limit.max_volume)
.map_err(|err| diesel::result::Error::DeserializationError(Box::new(err)))?,
window: chrono::Duration::seconds(limit.window_secs as i64),
window: Duration::seconds(limit.window_secs.into()),
},
};
@@ -265,7 +268,7 @@ impl Policy for EtherTransfer {
_log_id: i32,
_grant: &Grant<Self::Settings>,
_conn: &mut impl AsyncConnection<Backend = Sqlite>,
) -> diesel::result::QueryResult<()> {
) -> QueryResult<()> {
// Basic log is sufficient
Ok(())
@@ -318,7 +321,7 @@ impl Policy for EtherTransfer {
.map(|(basic, specific)| {
let targets: Vec<Address> = targets_by_grant
.get(&specific.id)
.map(|v| v.as_slice())
.map(Vec::as_slice)
.unwrap_or_default()
.iter()
.filter_map(|t| {
@@ -342,7 +345,7 @@ impl Policy for EtherTransfer {
max_volume: utils::try_bytes_to_u256(&limit.max_volume).map_err(
|e| diesel::result::Error::DeserializationError(Box::new(e)),
)?,
window: Duration::seconds(limit.window_secs as i64),
window: Duration::seconds(limit.window_secs.into()),
},
},
},

View File

@@ -1,27 +1,28 @@
use super::{EtherTransfer, Settings};
use crate::{
db::{
self, DatabaseConnection,
models::{
EvmBasicGrant, EvmWalletAccess, NewEvmBasicGrant, NewEvmTransactionLog, SqliteTimestamp,
},
schema::{evm_basic_grant, evm_transaction_log},
},
evm::{
policies::{
CombinedSettings, EvalContext, EvalViolation, Grant, Policy, SharedGrantSettings,
VolumeRateLimit,
},
utils,
},
};
use alloy::primitives::{Address, Bytes, U256, address};
use chrono::{Duration, Utc};
use diesel::{SelectableHelper, insert_into};
use diesel_async::RunQueryDsl;
use crate::db::{
self, DatabaseConnection,
models::{
EvmBasicGrant, EvmWalletAccess, NewEvmBasicGrant, NewEvmTransactionLog, SqliteTimestamp,
},
schema::{evm_basic_grant, evm_transaction_log},
};
use crate::evm::{
policies::{
CombinedSettings, EvalContext, EvalViolation, Grant, Policy, SharedGrantSettings,
VolumeRateLimit,
},
utils,
};
use super::{EtherTransfer, Settings};
const WALLET_ACCESS_ID: i32 = 1;
const CHAIN_ID: u64 = 1;
const CHAIN_ID: alloy::primitives::ChainId = 1;
const ALLOWED: Address = address!("1111111111111111111111111111111111111111");
const OTHER: Address = address!("2222222222222222222222222222222222222222");
@@ -47,7 +48,7 @@ async fn insert_basic(conn: &mut DatabaseConnection, revoked: bool) -> EvmBasicG
insert_into(evm_basic_grant::table)
.values(NewEvmBasicGrant {
wallet_access_id: WALLET_ACCESS_ID,
chain_id: CHAIN_ID as i32,
chain_id: CHAIN_ID.into(),
valid_from: None,
valid_until: None,
max_gas_fee_per_gas: None,
@@ -161,7 +162,7 @@ async fn evaluate_passes_when_volume_within_limit() {
.values(NewEvmTransactionLog {
grant_id,
wallet_access_id: WALLET_ACCESS_ID,
chain_id: CHAIN_ID as i32,
chain_id: CHAIN_ID.into(),
eth_value: utils::u256_to_bytes(U256::from(500u64)).to_vec(),
signed_at: SqliteTimestamp(Utc::now()),
})
@@ -203,7 +204,7 @@ async fn evaluate_rejects_volume_over_limit() {
.values(NewEvmTransactionLog {
grant_id,
wallet_access_id: WALLET_ACCESS_ID,
chain_id: CHAIN_ID as i32,
chain_id: CHAIN_ID.into(),
eth_value: utils::u256_to_bytes(U256::from(1_000u64)).to_vec(),
signed_at: SqliteTimestamp(Utc::now()),
})
@@ -246,7 +247,7 @@ async fn evaluate_passes_at_exactly_volume_limit() {
.values(NewEvmTransactionLog {
grant_id,
wallet_access_id: WALLET_ACCESS_ID,
chain_id: CHAIN_ID as i32,
chain_id: CHAIN_ID.into(),
eth_value: utils::u256_to_bytes(U256::from(900u64)).to_vec(),
signed_at: SqliteTimestamp(Utc::now()),
})

View File

@@ -1,16 +1,4 @@
use std::collections::HashMap;
use crate::db::schema::{
evm_basic_grant, evm_token_transfer_grant, evm_token_transfer_log,
evm_token_transfer_volume_limit,
};
use crate::evm::{
abi::IERC20::transferCall,
policies::{
Grant, Policy, SharedGrantSettings, SpecificGrant, SpecificMeaning, VolumeRateLimit,
},
utils,
};
use super::{DatabaseID, EvalContext, EvalViolation};
use crate::{
crypto::integrity::Integrable,
db::models::{
@@ -18,20 +6,33 @@ use crate::{
NewEvmTokenTransferGrant, NewEvmTokenTransferLog, NewEvmTokenTransferVolumeLimit,
SqliteTimestamp,
},
db::schema::{
evm_basic_grant, evm_token_transfer_grant, evm_token_transfer_log,
evm_token_transfer_volume_limit,
},
evm::policies::CombinedSettings,
evm::{
abi::IERC20::transferCall,
policies::{
Grant, Policy, SharedGrantSettings, SpecificGrant, SpecificMeaning, VolumeRateLimit,
},
utils,
},
};
use arbiter_tokens_registry::evm::nonfungible::{self, TokenInfo};
use alloy::{
primitives::{Address, U256},
sol_types::SolCall,
};
use arbiter_tokens_registry::evm::nonfungible::{self, TokenInfo};
use chrono::{DateTime, Duration, Utc};
use diesel::dsl::{auto_type, insert_into};
use diesel::sqlite::Sqlite;
use diesel::{ExpressionMethods, prelude::*};
use diesel::{
dsl::{auto_type, insert_into},
prelude::*,
sqlite::Sqlite,
};
use diesel_async::{AsyncConnection, RunQueryDsl};
use super::{DatabaseID, EvalContext, EvalViolation};
use std::collections::HashMap;
#[auto_type]
fn grant_join() -> _ {
@@ -56,8 +57,8 @@ impl std::fmt::Display for Meaning {
}
}
impl From<Meaning> for SpecificMeaning {
fn from(val: Meaning) -> SpecificMeaning {
SpecificMeaning::TokenTransfer(val)
fn from(val: Meaning) -> Self {
Self::TokenTransfer(val)
}
}
@@ -73,8 +74,8 @@ impl Integrable for Settings {
}
impl From<Settings> for SpecificGrant {
fn from(val: Settings) -> SpecificGrant {
SpecificGrant::TokenTransfer(val)
fn from(val: Settings) -> Self {
Self::TokenTransfer(val)
}
}
@@ -85,10 +86,7 @@ async fn query_relevant_past_transfers(
) -> QueryResult<Vec<(U256, DateTime<Utc>)>> {
let past_logs: Vec<(Vec<u8>, SqliteTimestamp)> = evm_token_transfer_log::table
.filter(evm_token_transfer_log::grant_id.eq(grant_id))
.filter(
evm_token_transfer_log::created_at
.ge(SqliteTimestamp(chrono::Utc::now() - longest_window)),
)
.filter(evm_token_transfer_log::created_at.ge(SqliteTimestamp(Utc::now() - longest_window)))
.select((
evm_token_transfer_log::value,
evm_token_transfer_log::created_at,
@@ -128,7 +126,7 @@ async fn check_volume_rate_limits(
let past_transfers = query_relevant_past_transfers(grant.id, longest_window, db).await?;
for limit in &grant.settings.specific.volume_limits {
let window_start = chrono::Utc::now() - limit.window;
let window_start = Utc::now() - limit.window;
let prospective_cumulative_volume: U256 = past_transfers
.iter()
.filter(|(_, timestamp)| timestamp >= &window_start)
@@ -204,6 +202,11 @@ impl Policy for TokenTransfer {
.await?;
for limit in &grant.volume_limits {
#[expect(
clippy::cast_possible_truncation,
clippy::as_conversions,
reason = "fixme! #86"
)]
insert_into(evm_token_transfer_volume_limit::table)
.values(NewEvmTokenTransferVolumeLimit {
grant_id,
@@ -253,7 +256,7 @@ impl Policy for TokenTransfer {
max_volume: utils::try_bytes_to_u256(&row.max_volume).map_err(|err| {
diesel::result::Error::DeserializationError(Box::new(err))
})?,
window: Duration::seconds(row.window_secs as i64),
window: Duration::seconds(row.window_secs.into()),
})
})
.collect::<QueryResult<Vec<_>>>()?;
@@ -303,7 +306,7 @@ impl Policy for TokenTransfer {
.values(NewEvmTokenTransferLog {
grant_id: grant.id,
log_id,
chain_id: context.chain as i32,
chain_id: context.chain.into(),
token_contract: context.to.to_vec(),
recipient_address: meaning.to.to_vec(),
value: utils::u256_to_bytes(meaning.value).to_vec(),
@@ -352,7 +355,7 @@ impl Policy for TokenTransfer {
.map(|(basic, specific)| {
let volume_limits: Vec<VolumeRateLimit> = limits_by_grant
.get(&specific.id)
.map(|v| v.as_slice())
.map(Vec::as_slice)
.unwrap_or_default()
.iter()
.map(|row| {
@@ -360,7 +363,7 @@ impl Policy for TokenTransfer {
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),
window: Duration::seconds(row.window_secs.into()),
})
})
.collect::<QueryResult<Vec<_>>>()?;

View File

@@ -1,25 +1,28 @@
use alloy::primitives::{Address, Bytes, U256, address};
use alloy::sol_types::SolCall;
use super::{Settings, TokenTransfer};
use crate::{
db::{
self, DatabaseConnection,
models::{EvmBasicGrant, EvmWalletAccess, NewEvmBasicGrant, SqliteTimestamp},
schema::evm_basic_grant,
},
evm::{
abi::IERC20::transferCall,
policies::{
CombinedSettings, EvalContext, EvalViolation, Grant, Policy, SharedGrantSettings,
VolumeRateLimit,
},
utils,
},
};
use alloy::{
primitives::{Address, Bytes, U256, address},
sol_types::SolCall,
};
use chrono::{Duration, Utc};
use diesel::{SelectableHelper, insert_into};
use diesel_async::RunQueryDsl;
use crate::db::{
self, DatabaseConnection,
models::{EvmBasicGrant, EvmWalletAccess, NewEvmBasicGrant, SqliteTimestamp},
schema::evm_basic_grant,
};
use crate::evm::{
abi::IERC20::transferCall,
policies::{
CombinedSettings, EvalContext, EvalViolation, Grant, Policy, SharedGrantSettings,
VolumeRateLimit,
},
utils,
};
use super::{Settings, TokenTransfer};
// DAI on Ethereum mainnet — present in the static token registry
const CHAIN_ID: u64 = 1;
const DAI: Address = address!("6B175474E89094C44Da98b954EedeAC495271d0F");
@@ -59,7 +62,7 @@ async fn insert_basic(conn: &mut DatabaseConnection, revoked: bool) -> EvmBasicG
insert_into(evm_basic_grant::table)
.values(NewEvmBasicGrant {
wallet_access_id: WALLET_ACCESS_ID,
chain_id: CHAIN_ID as i32,
chain_id: CHAIN_ID.into(),
valid_from: None,
valid_until: None,
max_gas_fee_per_gas: None,
@@ -239,12 +242,11 @@ async fn evaluate_passes_volume_at_exact_limit() {
.unwrap();
// Record a past transfer of 900, with current transfer 100 => exactly 1000 limit
use crate::db::{models::NewEvmTokenTransferLog, schema::evm_token_transfer_log};
insert_into(evm_token_transfer_log::table)
.values(NewEvmTokenTransferLog {
insert_into(db::schema::evm_token_transfer_log::table)
.values(db::models::NewEvmTokenTransferLog {
grant_id,
log_id: 0,
chain_id: CHAIN_ID as i32,
chain_id: CHAIN_ID.into(),
token_contract: DAI.to_vec(),
recipient_address: RECIPIENT.to_vec(),
value: utils::u256_to_bytes(U256::from(900u64)).to_vec(),
@@ -284,12 +286,11 @@ async fn evaluate_rejects_volume_over_limit() {
.await
.unwrap();
use crate::db::{models::NewEvmTokenTransferLog, schema::evm_token_transfer_log};
insert_into(evm_token_transfer_log::table)
.values(NewEvmTokenTransferLog {
insert_into(db::schema::evm_token_transfer_log::table)
.values(db::models::NewEvmTokenTransferLog {
grant_id,
log_id: 0,
chain_id: CHAIN_ID as i32,
chain_id: CHAIN_ID.into(),
token_contract: DAI.to_vec(),
recipient_address: RECIPIENT.to_vec(),
value: utils::u256_to_bytes(U256::from(1_000u64)).to_vec(),

View File

@@ -1,4 +1,4 @@
use std::sync::Mutex;
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use alloy::{
consensus::SignableTransaction,
@@ -6,9 +6,9 @@ use alloy::{
primitives::{Address, B256, ChainId, Signature},
signers::{Error, Result, Signer, SignerSync, utils::secret_key_to_address},
};
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use async_trait::async_trait;
use k256::ecdsa::{self, RecoveryId, SigningKey, signature::hazmat::PrehashSigner};
use std::sync::Mutex;
/// An Ethereum signer that stores its secp256k1 secret key inside a
/// hardware-protected [`MemSafe`] cell.
@@ -82,8 +82,8 @@ impl SafeSigner {
})
}
#[expect(clippy::significant_drop_tightening, reason = "false positive")]
fn sign_hash_inner(&self, hash: &B256) -> Result<Signature> {
#[allow(clippy::expect_used)]
let mut cell = self.key.lock().expect("SafeSigner mutex poisoned");
let reader = cell.read();
let sig: (ecdsa::Signature, RecoveryId) = reader.sign_prehash(hash.as_ref())?;
@@ -96,7 +96,6 @@ impl SafeSigner {
{
return Err(Error::TransactionChainIdMismatch {
signer: chain_id,
#[allow(clippy::expect_used)]
tx: tx.chain_id().expect("Chain ID is guaranteed to be set"),
});
}

View File

@@ -2,20 +2,20 @@ use alloy::primitives::U256;
#[derive(thiserror::Error, Debug)]
#[error("Expected {expected} bytes but got {actual} bytes")]
pub struct LengthError {
pub expected: usize,
pub actual: usize,
pub(super) struct LengthError {
pub(super) expected: usize,
pub(super) actual: usize,
}
pub fn u256_to_bytes(value: U256) -> [u8; 32] {
pub const fn u256_to_bytes(value: U256) -> [u8; 32] {
value.to_le_bytes()
}
pub fn bytes_to_u256(bytes: &[u8]) -> Option<U256> {
pub(super) fn bytes_to_u256(bytes: &[u8]) -> Option<U256> {
let bytes: [u8; 32] = bytes.try_into().ok()?;
Some(U256::from_le_bytes(bytes))
}
pub fn try_bytes_to_u256(bytes: &[u8]) -> diesel::result::QueryResult<U256> {
pub(super) fn try_bytes_to_u256(bytes: &[u8]) -> diesel::result::QueryResult<U256> {
let bytes: [u8; 32] = bytes.try_into().map_err(|_| {
diesel::result::Error::DeserializationError(Box::new(LengthError {
expected: 32,