From 5dfe390ac309bc89a233e06c513bf4ee3314973a Mon Sep 17 00:00:00 2001 From: hdbg Date: Mon, 9 Mar 2026 21:04:13 +0100 Subject: [PATCH] feat(evm): add grant management and transaction signing --- .../arbiter-server/src/actors/evm/mod.rs | 163 +++++++++++++++++- server/crates/arbiter-server/src/evm/mod.rs | 22 ++- .../arbiter-server/src/evm/safe_signer.rs | 12 ++ 3 files changed, 185 insertions(+), 12 deletions(-) diff --git a/server/crates/arbiter-server/src/actors/evm/mod.rs b/server/crates/arbiter-server/src/actors/evm/mod.rs index 709e3f4..0b7e97a 100644 --- a/server/crates/arbiter-server/src/actors/evm/mod.rs +++ b/server/crates/arbiter-server/src/actors/evm/mod.rs @@ -1,17 +1,56 @@ -use alloy::primitives::Address; -use diesel::{QueryDsl, SelectableHelper as _, dsl::insert_into}; +use alloy::{consensus::TxEip1559, network::TxSigner, primitives::Address, signers::Signature}; +use diesel::{ExpressionMethods, OptionalExtension as _, QueryDsl, SelectableHelper as _, dsl::insert_into}; use diesel_async::RunQueryDsl; use kameo::{Actor, actor::ActorRef, messages}; use memsafe::MemSafe; use rand::{SeedableRng, rng, rngs::StdRng}; use crate::{ - actors::keyholder::{CreateNew, KeyHolder}, - db::{self, DatabasePool, models, schema}, + actors::keyholder::{CreateNew, Decrypt, KeyHolder}, + db::{self, DatabasePool, models::{self, EvmBasicGrant, SqliteTimestamp}, schema}, + evm::{ + self, RunKind, + policies::{ + FullGrant, SharedGrantSettings, SpecificGrant, SpecificMeaning, + ether_transfer::EtherTransfer, + token_transfers::TokenTransfer, + }, + }, }; pub use crate::evm::safe_signer; +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +pub enum SignTransactionError { + #[error("Wallet not found")] + #[diagnostic(code(arbiter::evm::sign::wallet_not_found))] + WalletNotFound, + + #[error("Database error: {0}")] + #[diagnostic(code(arbiter::evm::sign::database))] + Database(#[from] diesel::result::Error), + + #[error("Database pool error: {0}")] + #[diagnostic(code(arbiter::evm::sign::pool))] + Pool(#[from] db::PoolError), + + #[error("Keyholder error: {0}")] + #[diagnostic(code(arbiter::evm::sign::keyholder))] + Keyholder(#[from] crate::actors::keyholder::Error), + + #[error("Keyholder mailbox error")] + #[diagnostic(code(arbiter::evm::sign::keyholder_send))] + KeyholderSend, + + #[error("Signing error: {0}")] + #[diagnostic(code(arbiter::evm::sign::signing))] + Signing(#[from] alloy::signers::Error), + + #[error("Policy error: {0}")] + #[diagnostic(code(arbiter::evm::sign::vet))] + Vet(#[from] evm::VetError), +} + #[derive(Debug, thiserror::Error, miette::Diagnostic)] pub enum Error { #[error("Keyholder error: {0}")] @@ -29,6 +68,10 @@ pub enum Error { #[error("Database pool error: {0}")] #[diagnostic(code(arbiter::evm::database_pool))] DatabasePool(#[from] db::PoolError), + + #[error("Grant creation error: {0}")] + #[diagnostic(code(arbiter::evm::creation))] + Creation(#[from] evm::CreationError), } #[derive(Actor)] @@ -36,6 +79,7 @@ pub struct EvmActor { pub keyholder: ActorRef, pub db: DatabasePool, pub rng: StdRng, + pub engine: evm::Engine, } impl EvmActor { @@ -43,7 +87,8 @@ impl EvmActor { // is it safe to seed rng from system once? // todo: audit let rng = StdRng::from_rng(&mut rng()); - Self { keyholder, db, rng } + let engine = evm::Engine::new(db.clone()); + Self { keyholder, db, rng, engine } } } @@ -91,3 +136,111 @@ impl EvmActor { .collect()) } } + +#[messages] +impl EvmActor { + #[message] + pub async fn useragent_create_grant( + &mut self, + client_id: i32, + basic: SharedGrantSettings, + grant: SpecificGrant, + ) -> Result { + match grant { + SpecificGrant::EtherTransfer(settings) => { + self.engine + .create_grant::(client_id, FullGrant { basic, specific: settings }) + .await + } + SpecificGrant::TokenTransfer(settings) => { + self.engine + .create_grant::(client_id, FullGrant { basic, specific: settings }) + .await + } + } + } + + #[message] + pub async fn useragent_delete_grant(&mut self, grant_id: i32) -> Result<(), Error> { + let mut conn = self.db.get().await?; + diesel::update(schema::evm_basic_grant::table) + .filter(schema::evm_basic_grant::id.eq(grant_id)) + .set(schema::evm_basic_grant::revoked_at.eq(SqliteTimestamp::now())) + .execute(&mut conn) + .await?; + Ok(()) + } + + #[message] + pub async fn useragent_list_grants( + &mut self, + wallet_id: Option, + ) -> Result, Error> { + let mut conn = self.db.get().await?; + let mut query = schema::evm_basic_grant::table + .select(EvmBasicGrant::as_select()) + .filter(schema::evm_basic_grant::revoked_at.is_null()) + .into_boxed(); + if let Some(wid) = wallet_id { + query = query.filter(schema::evm_basic_grant::wallet_id.eq(wid)); + } + Ok(query.load(&mut conn).await?) + } + + #[message] + pub async fn shared_analyze_transaction( + &mut self, + client_id: i32, + wallet_address: Address, + transaction: TxEip1559, + ) -> Result { + let mut conn = self.db.get().await?; + let wallet = schema::evm_wallet::table + .select(models::EvmWallet::as_select()) + .filter(schema::evm_wallet::address.eq(wallet_address.as_slice())) + .first(&mut conn) + .await + .optional()? + .ok_or(SignTransactionError::WalletNotFound)?; + drop(conn); + + let meaning = self.engine + .evaluate_transaction(wallet.id, client_id, transaction.clone(), RunKind::Execution) + .await?; + + Ok(meaning) + } + + #[message] + pub async fn client_sign_transaction( + &mut self, + client_id: i32, + wallet_address: Address, + mut transaction: TxEip1559, + ) -> Result { + let mut conn = self.db.get().await?; + let wallet = schema::evm_wallet::table + .select(models::EvmWallet::as_select()) + .filter(schema::evm_wallet::address.eq(wallet_address.as_slice())) + .first(&mut conn) + .await + .optional()? + .ok_or(SignTransactionError::WalletNotFound)?; + drop(conn); + + let raw_key: MemSafe> = self + .keyholder + .ask(Decrypt { aead_id: wallet.aead_encrypted_id }) + .await + .map_err(|_| SignTransactionError::KeyholderSend)?; + + let signer = safe_signer::SafeSigner::from_memsafe(raw_key)?; + + self.engine + .evaluate_transaction(wallet.id, client_id, transaction.clone(), RunKind::Execution) + .await?; + + use alloy::network::TxSignerSync as _; + Ok(signer.sign_transaction_sync(&mut transaction)?) + } +} diff --git a/server/crates/arbiter-server/src/evm/mod.rs b/server/crates/arbiter-server/src/evm/mod.rs index 5d96a9b..cd0a9f9 100644 --- a/server/crates/arbiter-server/src/evm/mod.rs +++ b/server/crates/arbiter-server/src/evm/mod.rs @@ -87,6 +87,15 @@ pub enum CreationError { Database(#[from] diesel::result::Error), } +/// Controls whether a transaction should be executed or only validated +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RunKind { + /// Validate and record the transaction + Execution, + /// Validate only, do not record + CheckOnly, +} + // 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, @@ -97,7 +106,7 @@ impl Engine { &self, context: EvalContext, meaning: &P::Meaning, - dry_run: bool, + run_kind: RunKind, ) -> Result<(), PolicyError> { let mut conn = self.db.get().await?; @@ -108,7 +117,7 @@ impl Engine { let violations = P::evaluate(&context, meaning, &grant, &mut conn).await?; if !violations.is_empty() { return Err(PolicyError::Violations(violations)); - } else if !dry_run { + } else if run_kind == RunKind::Execution { conn.transaction(|conn| { Box::pin(async move { let log_id: i32 = insert_into(evm_transaction_log::table) @@ -192,13 +201,12 @@ impl Engine { Ok(id) } - pub async fn evaluate_transaction( + pub async fn evaluate_transaction( &self, wallet_id: i32, client_id: i32, transaction: TxEip1559, - // don't log - dry_run: bool, + run_kind: RunKind, ) -> Result { let TxKind::Call(to) = transaction.to else { return Err(VetError::ContractCreationNotSupported); @@ -214,7 +222,7 @@ impl Engine { if let Some(meaning) = EtherTransfer::analyze(&context) { return match self - .vet_transaction::(context, &meaning, dry_run) + .vet_transaction::(context, &meaning, run_kind) .await { Ok(()) => Ok(meaning.into()), @@ -223,7 +231,7 @@ impl Engine { } if let Some(meaning) = TokenTransfer::analyze(&context) { return match self - .vet_transaction::(context, &meaning, dry_run) + .vet_transaction::(context, &meaning, run_kind) .await { Ok(()) => Ok(meaning.into()), diff --git a/server/crates/arbiter-server/src/evm/safe_signer.rs b/server/crates/arbiter-server/src/evm/safe_signer.rs index 952ebe6..5a2fdad 100644 --- a/server/crates/arbiter-server/src/evm/safe_signer.rs +++ b/server/crates/arbiter-server/src/evm/safe_signer.rs @@ -59,6 +59,18 @@ pub fn generate(rng: &mut impl rand::Rng) -> (MemSafe<[u8; 32]>, Address) { } impl SafeSigner { + /// Reconstructs a `SafeSigner` from key material held in a [`MemSafe`] buffer. + /// + /// The key bytes are read from protected memory, parsed as a secp256k1 + /// scalar, and immediately moved into a new [`MemSafe`] cell. The raw + /// bytes are never exposed outside this function. + pub fn from_memsafe(mut cell: MemSafe>) -> Result { + let reader = cell.read().map_err(Error::other)?; + let sk = SigningKey::from_slice(reader.as_slice()).map_err(Error::other)?; + drop(reader); + Self::new(sk) + } + /// Creates a new `SafeSigner` by moving the signing key into a protected /// memory region. pub fn new(key: SigningKey) -> Result {