feat(server): initial EVM functionality impl

This commit is contained in:
hdbg
2026-03-02 22:02:06 +01:00
parent cb05407bb6
commit 191b126462
20 changed files with 3954 additions and 44 deletions

2
.gitignore vendored
View File

@@ -1 +1,3 @@
target/ target/
scripts/__pycache__/
.DS_Store

31
protobufs/evm.proto Normal file
View File

@@ -0,0 +1,31 @@
syntax = "proto3";
package arbiter.evm;
enum EvmError {
EVM_ERROR_UNSPECIFIED = 0;
EVM_ERROR_VAULT_SEALED = 1;
EVM_ERROR_INTERNAL = 2;
}
message WalletEntry {
bytes address = 1; // 20-byte Ethereum address
}
message WalletList {
repeated WalletEntry wallets = 1;
}
message WalletCreateResponse {
oneof result {
WalletEntry wallet = 1;
EvmError error = 2;
}
}
message WalletListResponse {
oneof result {
WalletList wallets = 1;
EvmError error = 2;
}
}

View File

@@ -3,6 +3,7 @@ syntax = "proto3";
package arbiter.user_agent; package arbiter.user_agent;
import "google/protobuf/empty.proto"; import "google/protobuf/empty.proto";
import "evm.proto";
message AuthChallengeRequest { message AuthChallengeRequest {
bytes pubkey = 1; bytes pubkey = 1;
@@ -55,6 +56,8 @@ message UserAgentRequest {
UnsealStart unseal_start = 3; UnsealStart unseal_start = 3;
UnsealEncryptedKey unseal_encrypted_key = 4; UnsealEncryptedKey unseal_encrypted_key = 4;
google.protobuf.Empty query_vault_state = 5; google.protobuf.Empty query_vault_state = 5;
google.protobuf.Empty evm_wallet_create = 6;
google.protobuf.Empty evm_wallet_list = 7;
} }
} }
message UserAgentResponse { message UserAgentResponse {
@@ -64,5 +67,7 @@ message UserAgentResponse {
UnsealStartResponse unseal_start_response = 3; UnsealStartResponse unseal_start_response = 3;
UnsealResult unseal_result = 4; UnsealResult unseal_result = 4;
VaultState vault_state = 5; VaultState vault_state = 5;
arbiter.evm.WalletCreateResponse evm_wallet_create = 6;
arbiter.evm.WalletListResponse evm_wallet_list = 7;
} }
} }

2311
server/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,12 @@ resolver = "3"
[workspace.dependencies] [workspace.dependencies]
tonic = { version = "0.14.3", features = ["deflate", "gzip", "tls-connect-info", "zstd"] } tonic = { version = "0.14.3", features = [
"deflate",
"gzip",
"tls-connect-info",
"zstd",
] }
tracing = "0.1.44" tracing = "0.1.44"
tokio = { version = "1.49.0", features = ["full"] } tokio = { version = "1.49.0", features = ["full"] }
ed25519-dalek = { version = "3.0.0-pre.6", features = ["rand_core"] } ed25519-dalek = { version = "3.0.0-pre.6", features = ["rand_core"] }
@@ -27,6 +32,7 @@ prost-types = { version = "0.14.3", features = ["chrono"] }
x25519-dalek = { version = "2.0.1", features = ["getrandom"] } x25519-dalek = { version = "2.0.1", features = ["getrandom"] }
rstest = "0.26.1" rstest = "0.26.1"
rustls-pki-types = "1.14.0" rustls-pki-types = "1.14.0"
alloy = "1.7.3"
rcgen = { version = "0.14.7", features = [ rcgen = { version = "0.14.7", features = [
"aws_lc_rs", "aws_lc_rs",
"pem", "pem",

View File

@@ -13,6 +13,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
format!("{}/arbiter.proto", PROTOBUF_DIR), format!("{}/arbiter.proto", PROTOBUF_DIR),
format!("{}/user_agent.proto", PROTOBUF_DIR), format!("{}/user_agent.proto", PROTOBUF_DIR),
format!("{}/client.proto", PROTOBUF_DIR), format!("{}/client.proto", PROTOBUF_DIR),
format!("{}/evm.proto", PROTOBUF_DIR),
], ],
&[PROTOBUF_DIR.to_string()], &[PROTOBUF_DIR.to_string()],
) )

View File

@@ -13,6 +13,10 @@ pub mod proto {
pub mod client { pub mod client {
tonic::include_proto!("arbiter.client"); tonic::include_proto!("arbiter.client");
} }
pub mod evm {
tonic::include_proto!("arbiter.evm");
}
} }
pub static BOOTSTRAP_PATH: &str = "bootstrap_token"; pub static BOOTSTRAP_PATH: &str = "bootstrap_token";

View File

@@ -42,6 +42,8 @@ argon2 = { version = "0.5.3", features = ["zeroize"] }
restructed = "0.2.2" restructed = "0.2.2"
strum = { version = "0.27.2", features = ["derive"] } strum = { version = "0.27.2", features = ["derive"] }
pem = "3.0.6" pem = "3.0.6"
k256 = "0.13.4"
alloy.workspace = true
[dev-dependencies] [dev-dependencies]
insta = "1.46.3" insta = "1.46.3"

View File

@@ -57,3 +57,170 @@ create table if not exists program_client (
created_at integer not null default(unixepoch ('now')), created_at integer not null default(unixepoch ('now')),
updated_at integer not null default(unixepoch ('now')) updated_at integer not null default(unixepoch ('now'))
) STRICT; ) STRICT;
create table if not exists evm_wallet (
id integer not null primary key,
address blob not null, -- 20-byte Ethereum address
aead_encrypted_id integer not null references aead_encrypted (id) on delete RESTRICT,
created_at integer not null default(unixepoch ('now'))
) STRICT;
create unique index if not exists uniq_evm_wallet_address on evm_wallet (address);
create unique index if not exists uniq_evm_wallet_aead on evm_wallet (aead_encrypted_id);
-- Shared grant properties: client scope, timeframe, fee caps, and rate limit
create table if not exists evm_basic_grant (
id integer not null primary key,
wallet_id integer not null references evm_wallet(id) on delete restrict,
client_id integer not null references program_client(id) on delete restrict,
chain_id integer not null, -- EIP-155 chain ID
valid_from integer, -- unix timestamp (seconds), null = no lower bound
valid_until integer, -- unix timestamp (seconds), null = no upper bound
max_gas_fee_per_gas blob, -- big-endian 32-byte U256, null = unlimited
max_priority_fee_per_gas blob, -- big-endian 32-byte U256, null = unlimited
rate_limit_count integer, -- max transactions in window, null = unlimited
rate_limit_window_secs integer, -- window duration in seconds, null = unlimited
revoked_at integer, -- unix timestamp when revoked, null = still active
created_at integer not null default(unixepoch('now'))
) STRICT;
create index if not exists idx_evm_basic_grant_wallet_chain on evm_basic_grant(client_id, wallet_id, chain_id);
-- ERC20 token transfer grant
create table if not exists evm_token_transfer_grant (
id integer not null primary key,
basic_grant_id integer not null unique references evm_basic_grant(id) on delete cascade,
token_contract blob not null -- 20-byte ERC20 contract address
) STRICT;
-- Specific recipient addresses for a token transfer grant (only used when target_all = 0)
create table if not exists evm_token_transfer_grant_target (
id integer not null primary key,
grant_id integer not null references evm_token_transfer_grant(id) on delete cascade,
address blob not null -- 20-byte recipient address
) STRICT;
create unique index if not exists uniq_token_transfer_target on evm_token_transfer_grant_target(grant_id, address);
-- Per-window volume limits for token transfer grants
create table if not exists evm_token_transfer_volume_limit (
id integer not null primary key,
grant_id integer not null references evm_token_transfer_grant(id) on delete cascade,
window_secs integer not null, -- window duration in seconds
max_volume blob not null -- big-endian 32-byte U256
) STRICT;
-- ERC20 token approval grant
create table if not exists evm_token_approval_grant (
id integer not null primary key,
basic_grant_id integer not null unique references evm_basic_grant(id) on delete cascade,
token_contract blob not null, -- 20-byte ERC20 contract address
max_total_approval blob not null -- big-endian 32-byte U256; max cumulative approval value
) STRICT;
-- Specific spender addresses for a token approval grant (only used when target_all = 0)
create table if not exists evm_token_approval_grant_target (
id integer not null primary key,
grant_id integer not null references evm_token_approval_grant(id) on delete cascade,
address blob not null -- 20-byte spender address
) STRICT;
create unique index if not exists uniq_token_approval_target on evm_token_approval_grant_target(grant_id, address);
-- Plain ether transfer grant
create table if not exists evm_ether_transfer_grant (
id integer not null primary key,
basic_grant_id integer not null unique references evm_basic_grant(id) on delete cascade
) STRICT;
-- Specific recipient addresses for an ether transfer grant (only used when target_all = 0)
create table if not exists evm_ether_transfer_grant_target (
id integer not null primary key,
grant_id integer not null references evm_ether_transfer_grant(id) on delete cascade,
address blob not null -- 20-byte recipient address
) STRICT;
create unique index if not exists uniq_ether_transfer_target on evm_ether_transfer_grant_target(grant_id, address);
-- Per-window volume limits for ether transfer grants
create table if not exists evm_ether_transfer_volume_limit (
id integer not null primary key,
grant_id integer not null references evm_ether_transfer_grant(id) on delete cascade,
window_secs integer not null,
max_volume blob not null -- big-endian 32-byte U256
) STRICT;
-- Unknown / opaque contract call grant
create table if not exists evm_unknown_call_grant (
id integer not null primary key,
basic_grant_id integer not null unique references evm_basic_grant(id) on delete cascade,
contract blob not null, -- 20-byte target contract address
selector blob -- 4-byte function selector, null = allow any selector
) STRICT;
-- Log table for ether transfer grant usage
create table if not exists evm_ether_transfer_log (
id integer not null primary key,
grant_id integer not null references evm_ether_transfer_grant(id) on delete restrict,
client_id integer not null references program_client(id) on delete restrict,
wallet_id integer not null references evm_wallet(id) on delete restrict,
chain_id integer not null, -- EIP-155 chain ID
recipient_address blob not null, -- 20-byte recipient address
value blob not null, -- big-endian 32-byte U256
created_at integer not null default(unixepoch('now'))
) STRICT;
create index if not exists idx_ether_transfer_log_grant on evm_ether_transfer_log(grant_id);
create index if not exists idx_ether_transfer_log_client on evm_ether_transfer_log(client_id);
create index if not exists idx_ether_transfer_log_wallet on evm_ether_transfer_log(wallet_id);
-- Log table for token transfer grant usage
create table if not exists evm_token_transfer_log (
id integer not null primary key,
grant_id integer not null references evm_token_transfer_grant(id) on delete restrict,
client_id integer not null references program_client(id) on delete restrict,
wallet_id integer not null references evm_wallet(id) on delete restrict,
chain_id integer not null, -- EIP-155 chain ID
token_contract blob not null, -- 20-byte ERC20 contract address
recipient_address blob not null, -- 20-byte recipient address
value blob not null, -- big-endian 32-byte U256
created_at integer not null default(unixepoch('now'))
) STRICT;
create index if not exists idx_token_transfer_log_grant on evm_token_transfer_log(grant_id);
create index if not exists idx_token_transfer_log_client on evm_token_transfer_log(client_id);
create index if not exists idx_token_transfer_log_wallet on evm_token_transfer_log(wallet_id);
-- Log table for token approval grant usage
create table if not exists evm_token_approval_log (
id integer not null primary key,
grant_id integer not null references evm_token_approval_grant(id) on delete restrict,
client_id integer not null references program_client(id) on delete restrict,
wallet_id integer not null references evm_wallet(id) on delete restrict,
chain_id integer not null, -- EIP-155 chain ID
token_contract blob not null, -- 20-byte ERC20 contract address
spender_address blob not null, -- 20-byte spender address
value blob not null, -- big-endian 32-byte U256
created_at integer not null default(unixepoch('now'))
) STRICT;
create index if not exists idx_token_approval_log_grant on evm_token_approval_log(grant_id);
create index if not exists idx_token_approval_log_client on evm_token_approval_log(client_id);
create index if not exists idx_token_approval_log_wallet on evm_token_approval_log(wallet_id);
-- Log table for unknown contract call grant usage
create table if not exists evm_unknown_call_log (
id integer not null primary key,
grant_id integer not null references evm_unknown_call_grant(id) on delete restrict,
client_id integer not null references program_client(id) on delete restrict,
wallet_id integer not null references evm_wallet(id) on delete restrict,
chain_id integer not null, -- EIP-155 chain ID
contract blob not null, -- 20-byte target contract address
selector blob, -- 4-byte function selector, null if none
call_data blob, -- full call data, null if not stored
created_at integer not null default(unixepoch('now'))
) STRICT;
create index if not exists idx_unknown_call_log_grant on evm_unknown_call_log(grant_id);
create index if not exists idx_unknown_call_log_client on evm_unknown_call_log(client_id);
create index if not exists idx_unknown_call_log_wallet on evm_unknown_call_log(wallet_id);

View File

@@ -0,0 +1,93 @@
use alloy::primitives::Address;
use diesel::{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},
};
pub use crate::evm::safe_signer;
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
pub enum Error {
#[error("Keyholder error: {0}")]
#[diagnostic(code(arbiter::evm::keyholder))]
Keyholder(#[from] crate::actors::keyholder::Error),
#[error("Keyholder mailbox error")]
#[diagnostic(code(arbiter::evm::keyholder_send))]
KeyholderSend,
#[error("Database error: {0}")]
#[diagnostic(code(arbiter::evm::database))]
Database(#[from] diesel::result::Error),
#[error("Database pool error: {0}")]
#[diagnostic(code(arbiter::evm::database_pool))]
DatabasePool(#[from] db::PoolError),
}
#[derive(Actor)]
pub struct EvmActor {
pub keyholder: ActorRef<KeyHolder>,
pub db: DatabasePool,
pub rng: StdRng,
}
impl EvmActor {
pub fn new(keyholder: ActorRef<KeyHolder>, db: DatabasePool) -> Self {
// is it safe to seed rng from system once?
// todo: audit
let rng = StdRng::from_rng(&mut rng());
Self { keyholder, db, rng }
}
}
#[messages]
impl EvmActor {
#[message]
pub async fn generate(&mut self) -> Result<Address, Error> {
let (mut key_cell, address) = safe_signer::generate(&mut self.rng);
// Move raw key bytes into a Vec<u8> MemSafe for KeyHolder
let plaintext = {
let reader = key_cell.read().expect("MemSafe read");
MemSafe::new(reader.to_vec()).expect("MemSafe allocation")
};
let aead_id: i32 = self
.keyholder
.ask(CreateNew { plaintext })
.await
.map_err(|_| Error::KeyholderSend)?;
let mut conn = self.db.get().await?;
insert_into(schema::evm_wallet::table)
.values(&models::NewEvmWallet {
address: address.as_slice().to_vec(),
aead_encrypted_id: aead_id,
})
.execute(&mut conn)
.await?;
Ok(address)
}
#[message]
pub async fn list_wallets(&self) -> Result<Vec<Address>, Error> {
let mut conn = self.db.get().await?;
let rows: Vec<models::EvmWallet> = schema::evm_wallet::table
.select(models::EvmWallet::as_select())
.load(&mut conn)
.await?;
Ok(rows
.into_iter()
.map(|w| Address::from_slice(&w.address))
.collect())
}
}

View File

@@ -3,15 +3,16 @@ use miette::Diagnostic;
use thiserror::Error; use thiserror::Error;
use crate::{ use crate::{
actors::{bootstrap::Bootstrapper, keyholder::KeyHolder, router::MessageRouter}, actors::{bootstrap::Bootstrapper, evm::EvmActor, keyholder::KeyHolder, router::MessageRouter},
db, db,
}; };
pub mod bootstrap; pub mod bootstrap;
pub mod router;
pub mod keyholder;
pub mod user_agent;
pub mod client; pub mod client;
mod evm;
pub mod keyholder;
pub mod router;
pub mod user_agent;
#[derive(Error, Debug, Diagnostic)] #[derive(Error, Debug, Diagnostic)]
pub enum SpawnError { pub enum SpawnError {
@@ -30,13 +31,16 @@ pub struct GlobalActors {
pub key_holder: ActorRef<KeyHolder>, pub key_holder: ActorRef<KeyHolder>,
pub bootstrapper: ActorRef<Bootstrapper>, pub bootstrapper: ActorRef<Bootstrapper>,
pub router: ActorRef<MessageRouter>, pub router: ActorRef<MessageRouter>,
pub evm: ActorRef<EvmActor>,
} }
impl GlobalActors { impl GlobalActors {
pub async fn spawn(db: db::DatabasePool) -> Result<Self, SpawnError> { pub async fn spawn(db: db::DatabasePool) -> Result<Self, SpawnError> {
let key_holder = KeyHolder::spawn(KeyHolder::new(db.clone()).await?);
Ok(Self { Ok(Self {
bootstrapper: Bootstrapper::spawn(Bootstrapper::new(&db).await?), bootstrapper: Bootstrapper::spawn(Bootstrapper::new(&db).await?),
key_holder: KeyHolder::spawn(KeyHolder::new(db.clone()).await?), evm: EvmActor::spawn(EvmActor::new(key_holder.clone(), db)),
key_holder,
router: MessageRouter::spawn(MessageRouter::default()), router: MessageRouter::spawn(MessageRouter::default()),
}) })
} }

View File

@@ -1,19 +1,26 @@
use std::{ops::DerefMut, sync::Mutex}; use std::{ops::DerefMut, sync::Mutex};
use arbiter_proto::proto::user_agent::{ use arbiter_proto::proto::{
evm as evm_proto,
user_agent::{
UnsealEncryptedKey, UnsealResult, UnsealStart, UnsealStartResponse, UserAgentRequest, UnsealEncryptedKey, UnsealResult, UnsealStart, UnsealStartResponse, UserAgentRequest,
UserAgentResponse, user_agent_request::Payload as UserAgentRequestPayload, UserAgentResponse, user_agent_request::Payload as UserAgentRequestPayload,
user_agent_response::Payload as UserAgentResponsePayload, user_agent_response::Payload as UserAgentResponsePayload,
},
}; };
use chacha20poly1305::{AeadInPlace, XChaCha20Poly1305, XNonce, aead::KeyInit}; use chacha20poly1305::{AeadInPlace, XChaCha20Poly1305, XNonce, aead::KeyInit};
use ed25519_dalek::VerifyingKey; use ed25519_dalek::VerifyingKey;
use kameo::{Actor, error::SendError}; use kameo::{
Actor,
error::SendError,
};
use memsafe::MemSafe; use memsafe::MemSafe;
use tokio::select; use tokio::select;
use tracing::{error, info}; use tracing::{error, info};
use x25519_dalek::{EphemeralSecret, PublicKey}; use x25519_dalek::{EphemeralSecret, PublicKey};
use crate::actors::{ use crate::actors::{
evm::{Generate, ListWallets},
keyholder::{self, TryUnseal}, keyholder::{self, TryUnseal},
router::RegisterUserAgent, router::RegisterUserAgent,
user_agent::{UserAgentConnection, UserAgentError}, user_agent::{UserAgentConnection, UserAgentError},
@@ -58,6 +65,8 @@ impl UserAgentSession {
UserAgentRequestPayload::UnsealEncryptedKey(unseal_encrypted_key) => { UserAgentRequestPayload::UnsealEncryptedKey(unseal_encrypted_key) => {
self.handle_unseal_encrypted_key(unseal_encrypted_key).await self.handle_unseal_encrypted_key(unseal_encrypted_key).await
} }
UserAgentRequestPayload::EvmWalletCreate(_) => self.handle_evm_wallet_create().await,
UserAgentRequestPayload::EvmWalletList(_) => self.handle_evm_wallet_list().await,
_ => Err(UserAgentError::UnexpectedRequestPayload), _ => Err(UserAgentError::UnexpectedRequestPayload),
} }
} }
@@ -178,6 +187,64 @@ impl UserAgentSession {
} }
} }
impl UserAgentSession {
async fn handle_evm_wallet_create(&mut self) -> Output {
use evm_proto::wallet_create_response::Result as CreateResult;
let result = match self.props.actors.evm.ask(Generate {}).await {
Ok(address) => CreateResult::Wallet(evm_proto::WalletEntry {
address: address.as_slice().to_vec(),
}),
Err(err) => CreateResult::Error(map_evm_error("wallet create", err).into()),
};
Ok(response(UserAgentResponsePayload::EvmWalletCreate(
evm_proto::WalletCreateResponse {
result: Some(result),
},
)))
}
async fn handle_evm_wallet_list(&mut self) -> Output {
use evm_proto::wallet_list_response::Result as ListResult;
let result = match self.props.actors.evm.ask(ListWallets {}).await {
Ok(wallets) => ListResult::Wallets(evm_proto::WalletList {
wallets: wallets
.into_iter()
.map(|addr| evm_proto::WalletEntry {
address: addr.as_slice().to_vec(),
})
.collect(),
}),
Err(err) => ListResult::Error(map_evm_error("wallet list", err).into()),
};
Ok(response(UserAgentResponsePayload::EvmWalletList(
evm_proto::WalletListResponse {
result: Some(result),
},
)))
}
}
fn map_evm_error<M>(op: &str, err: SendError<M, crate::actors::evm::Error>) -> evm_proto::EvmError {
use crate::actors::{evm::Error as EvmError, keyholder::Error as KhError};
match err {
SendError::HandlerError(EvmError::Keyholder(KhError::NotBootstrapped)) => {
evm_proto::EvmError::VaultSealed
}
SendError::HandlerError(err) => {
error!(?err, "EVM {op} failed");
evm_proto::EvmError::Internal
}
_ => {
error!("EVM actor unreachable during {op}");
evm_proto::EvmError::Internal
}
}
}
impl Actor for UserAgentSession { impl Actor for UserAgentSession {
type Args = Self; type Args = Self;

View File

@@ -1,14 +1,67 @@
#![allow(unused)] #![allow(unused)]
#![allow(clippy::all)] #![allow(clippy::all)]
use crate::db::schema::{self, aead_encrypted, arbiter_settings, root_key_history, tls_history}; use crate::db::schema::{
self, aead_encrypted, arbiter_settings, evm_basic_grant, evm_ether_transfer_grant,
evm_ether_transfer_grant_target, evm_ether_transfer_log, evm_ether_transfer_volume_limit,
evm_token_approval_grant, evm_token_approval_grant_target,
evm_token_approval_log, evm_token_transfer_grant, evm_token_transfer_grant_target,
evm_token_transfer_log, evm_token_transfer_volume_limit, evm_unknown_call_grant,
evm_unknown_call_log, evm_wallet, root_key_history, tls_history,
};
use chrono::{DateTime, Utc};
use diesel::{prelude::*, sqlite::Sqlite}; use diesel::{prelude::*, sqlite::Sqlite};
use restructed::Models; use restructed::Models;
pub mod types { pub mod types {
use std::os::unix;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
pub struct SqliteTimestamp(DateTime<Utc>); use diesel::{
deserialize::{FromSql, FromSqlRow},
expression::AsExpression,
serialize::{IsNull, ToSql},
sql_types::Integer,
sqlite::{Sqlite, SqliteType},
};
#[derive(Debug, FromSqlRow, AsExpression)]
#[sql_type = "Integer"]
#[repr(transparent)] // hint compiler to optimize the wrapper struct away
pub struct SqliteTimestamp(pub DateTime<Utc>);
impl ToSql<Integer, Sqlite> for SqliteTimestamp {
fn to_sql<'b>(
&'b self,
out: &mut diesel::serialize::Output<'b, '_, Sqlite>,
) -> diesel::serialize::Result {
let unix_timestamp = self.0.timestamp() as i32;
out.set_value(unix_timestamp);
Ok(IsNull::No)
} }
}
impl FromSql<Integer, Sqlite> for SqliteTimestamp {
fn from_sql(
mut bytes: <Sqlite as diesel::backend::Backend>::RawValue<'_>,
) -> diesel::deserialize::Result<Self> {
let Some(SqliteType::Integer) = bytes.value_type() else {
return Err(format!(
"Expected Integer type for SqliteTimestamp, got {:?}",
bytes.value_type()
)
.into());
};
let unix_timestamp = bytes.read_integer();
let datetime = DateTime::from_timestamp(unix_timestamp as i64, 0)
.ok_or("Timestamp is out of bounds")?;
Ok(SqliteTimestamp(datetime))
}
}
}
pub use types::*;
#[derive(Models, Queryable, Debug, Insertable, Selectable)] #[derive(Models, Queryable, Debug, Insertable, Selectable)]
#[view( #[view(
@@ -60,7 +113,7 @@ pub struct TlsHistory {
pub cert_key: String, // PEM Encoded private key pub cert_key: String, // PEM Encoded private key
pub ca_cert: String, // PEM Encoded certificate for cert signing pub ca_cert: String, // PEM Encoded certificate for cert signing
pub ca_key: String, // PEM Encoded public key for cert signing pub ca_key: String, // PEM Encoded public key for cert signing
pub created_at: i32, pub created_at: SqliteTimestamp,
} }
#[derive(Queryable, Debug, Insertable, Selectable)] #[derive(Queryable, Debug, Insertable, Selectable)]
@@ -71,14 +124,29 @@ pub struct ArbiterSettings {
pub tls_id: Option<i32>, // references tls_history.id pub tls_id: Option<i32>, // references tls_history.id
} }
#[derive(Models, Queryable, Debug, Insertable, Selectable)]
#[diesel(table_name = evm_wallet, check_for_backend(Sqlite))]
#[view(
NewEvmWallet,
derive(Insertable),
omit(id, created_at),
attributes_with = "deriveless"
)]
pub struct EvmWallet {
pub id: i32,
pub address: Vec<u8>,
pub aead_encrypted_id: i32,
pub created_at: SqliteTimestamp,
}
#[derive(Queryable, Debug)] #[derive(Queryable, Debug)]
#[diesel(table_name = schema::program_client, check_for_backend(Sqlite))] #[diesel(table_name = schema::program_client, check_for_backend(Sqlite))]
pub struct ProgramClient { pub struct ProgramClient {
pub id: i32, pub id: i32,
pub public_key: Vec<u8>, pub public_key: Vec<u8>,
pub nonce: i32, pub nonce: i32,
pub created_at: i32, pub created_at: SqliteTimestamp,
pub updated_at: i32, pub updated_at: SqliteTimestamp,
} }
#[derive(Queryable, Debug)] #[derive(Queryable, Debug)]
@@ -87,6 +155,237 @@ pub struct UseragentClient {
pub id: i32, pub id: i32,
pub public_key: Vec<u8>, pub public_key: Vec<u8>,
pub nonce: i32, pub nonce: i32,
pub created_at: i32, pub created_at: SqliteTimestamp,
pub updated_at: i32, pub updated_at: SqliteTimestamp,
}
#[derive(Models, Queryable, Debug, Insertable, Selectable)]
#[diesel(table_name = evm_basic_grant, check_for_backend(Sqlite))]
#[view(
NewEvmBasicGrant,
derive(Insertable),
omit(id, created_at),
attributes_with = "deriveless"
)]
pub struct EvmBasicGrant {
pub id: i32,
pub wallet_id: i32, // references evm_wallet.id
pub client_id: i32, // references program_client.id
pub chain_id: i32,
pub valid_from: Option<SqliteTimestamp>,
pub valid_until: Option<SqliteTimestamp>,
pub max_gas_fee_per_gas: Option<Vec<u8>>,
pub max_priority_fee_per_gas: Option<Vec<u8>>,
pub rate_limit_count: Option<i32>,
pub rate_limit_window_secs: Option<i32>,
pub revoked_at: Option<SqliteTimestamp>,
pub created_at: SqliteTimestamp,
}
#[derive(Models, Queryable, Debug, Insertable, Selectable)]
#[diesel(table_name = evm_ether_transfer_grant, check_for_backend(Sqlite))]
#[view(
NewEvmEtherTransferGrant,
derive(Insertable),
omit(id),
attributes_with = "deriveless"
)]
pub struct EvmEtherTransferGrant {
pub id: i32,
pub basic_grant_id: i32,
}
#[derive(Models, Queryable, Debug, Insertable, Selectable)]
#[diesel(table_name = evm_ether_transfer_grant_target, check_for_backend(Sqlite))]
#[view(
NewEvmEtherTransferGrantTarget,
derive(Insertable),
omit(id),
attributes_with = "deriveless"
)]
pub struct EvmEtherTransferGrantTarget {
pub id: i32,
pub grant_id: i32,
pub address: Vec<u8>,
}
#[derive(Models, Queryable, Debug, Insertable, Selectable)]
#[diesel(table_name = evm_ether_transfer_volume_limit, check_for_backend(Sqlite))]
#[view(
NewEvmEtherTransferVolumeLimit,
derive(Insertable),
omit(id),
attributes_with = "deriveless"
)]
pub struct EvmEtherTransferVolumeLimit {
pub id: i32,
pub grant_id: i32,
pub window_secs: i32,
pub max_volume: Vec<u8>,
}
#[derive(Models, Queryable, Debug, Insertable, Selectable)]
#[diesel(table_name = evm_token_approval_grant, check_for_backend(Sqlite))]
#[view(
NewEvmTokenApprovalGrant,
derive(Insertable),
omit(id),
attributes_with = "deriveless"
)]
pub struct EvmTokenApprovalGrant {
pub id: i32,
pub basic_grant_id: i32,
pub token_contract: Vec<u8>,
pub max_total_approval: Vec<u8>,
}
#[derive(Models, Queryable, Debug, Insertable, Selectable)]
#[diesel(table_name = evm_token_approval_grant_target, check_for_backend(Sqlite))]
#[view(
NewEvmTokenApprovalGrantTarget,
derive(Insertable),
omit(id),
attributes_with = "deriveless"
)]
pub struct EvmTokenApprovalGrantTarget {
pub id: i32,
pub grant_id: i32,
pub address: Vec<u8>,
}
#[derive(Models, Queryable, Debug, Insertable, Selectable)]
#[diesel(table_name = evm_token_transfer_grant, check_for_backend(Sqlite))]
#[view(
NewEvmTokenTransferGrant,
derive(Insertable),
omit(id),
attributes_with = "deriveless"
)]
pub struct EvmTokenTransferGrant {
pub id: i32,
pub basic_grant_id: i32,
pub token_contract: Vec<u8>,
}
#[derive(Models, Queryable, Debug, Insertable, Selectable)]
#[diesel(table_name = evm_token_transfer_grant_target, check_for_backend(Sqlite))]
#[view(
NewEvmTokenTransferGrantTarget,
derive(Insertable),
omit(id),
attributes_with = "deriveless"
)]
pub struct EvmTokenTransferGrantTarget {
pub id: i32,
pub grant_id: i32,
pub address: Vec<u8>,
}
#[derive(Models, Queryable, Debug, Insertable, Selectable)]
#[diesel(table_name = evm_token_transfer_volume_limit, check_for_backend(Sqlite))]
#[view(
NewEvmTokenTransferVolumeLimit,
derive(Insertable),
omit(id),
attributes_with = "deriveless"
)]
pub struct EvmTokenTransferVolumeLimit {
pub id: i32,
pub grant_id: i32,
pub window_secs: i32,
pub max_volume: Vec<u8>,
}
#[derive(Models, Queryable, Debug, Insertable, Selectable)]
#[diesel(table_name = evm_unknown_call_grant, check_for_backend(Sqlite))]
#[view(
NewEvmUnknownCallGrant,
derive(Insertable),
omit(id),
attributes_with = "deriveless"
)]
pub struct EvmUnknownCallGrant {
pub id: i32,
pub basic_grant_id: i32,
pub contract: Vec<u8>,
pub selector: Option<Vec<u8>>,
}
#[derive(Models, Queryable, Debug, Insertable, Selectable)]
#[diesel(table_name = evm_ether_transfer_log, check_for_backend(Sqlite))]
#[view(
NewEvmEtherTransferLog,
derive(Insertable),
omit(id, created_at),
attributes_with = "deriveless"
)]
pub struct EvmEtherTransferLog {
pub id: i32,
pub grant_id: i32,
pub client_id: i32,
pub wallet_id: i32,
pub chain_id: i32,
pub recipient_address: Vec<u8>,
pub value: Vec<u8>,
pub created_at: SqliteTimestamp,
}
#[derive(Models, Queryable, Debug, Insertable, Selectable)]
#[diesel(table_name = evm_token_transfer_log, check_for_backend(Sqlite))]
#[view(
NewEvmTokenTransferLog,
derive(Insertable),
omit(id, created_at),
attributes_with = "deriveless"
)]
pub struct EvmTokenTransferLog {
pub id: i32,
pub grant_id: i32,
pub client_id: i32,
pub wallet_id: i32,
pub chain_id: i32,
pub token_contract: Vec<u8>,
pub recipient_address: Vec<u8>,
pub value: Vec<u8>,
pub created_at: SqliteTimestamp,
}
#[derive(Models, Queryable, Debug, Insertable, Selectable)]
#[diesel(table_name = evm_token_approval_log, check_for_backend(Sqlite))]
#[view(
NewEvmTokenApprovalLog,
derive(Insertable),
omit(id, created_at),
attributes_with = "deriveless"
)]
pub struct EvmTokenApprovalLog {
pub id: i32,
pub grant_id: i32,
pub client_id: i32,
pub wallet_id: i32,
pub chain_id: i32,
pub token_contract: Vec<u8>,
pub spender_address: Vec<u8>,
pub value: Vec<u8>,
pub created_at: SqliteTimestamp,
}
#[derive(Models, Queryable, Debug, Insertable, Selectable)]
#[diesel(table_name = evm_unknown_call_log, check_for_backend(Sqlite))]
#[view(
NewEvmUnknownCallLog,
derive(Insertable),
omit(id, created_at),
attributes_with = "deriveless"
)]
pub struct EvmUnknownCallLog {
pub id: i32,
pub grant_id: i32,
pub client_id: i32,
pub wallet_id: i32,
pub chain_id: i32,
pub contract: Vec<u8>,
pub selector: Option<Vec<u8>>,
pub call_data: Option<Vec<u8>>,
pub created_at: SqliteTimestamp,
} }

View File

@@ -20,6 +20,162 @@ diesel::table! {
} }
} }
diesel::table! {
evm_basic_grant (id) {
id -> Integer,
wallet_id -> Integer,
client_id -> Integer,
chain_id -> Integer,
valid_from -> Nullable<Integer>,
valid_until -> Nullable<Integer>,
max_gas_fee_per_gas -> Nullable<Binary>,
max_priority_fee_per_gas -> Nullable<Binary>,
rate_limit_count -> Nullable<Integer>,
rate_limit_window_secs -> Nullable<Integer>,
revoked_at -> Nullable<Integer>,
created_at -> Integer,
}
}
diesel::table! {
evm_ether_transfer_grant (id) {
id -> Integer,
basic_grant_id -> Integer,
}
}
diesel::table! {
evm_ether_transfer_grant_target (id) {
id -> Integer,
grant_id -> Integer,
address -> Binary,
}
}
diesel::table! {
evm_ether_transfer_log (id) {
id -> Integer,
grant_id -> Integer,
client_id -> Integer,
wallet_id -> Integer,
chain_id -> Integer,
recipient_address -> Binary,
value -> Binary,
created_at -> Integer,
}
}
diesel::table! {
evm_ether_transfer_volume_limit (id) {
id -> Integer,
grant_id -> Integer,
window_secs -> Integer,
max_volume -> Binary,
}
}
diesel::table! {
evm_token_approval_grant (id) {
id -> Integer,
basic_grant_id -> Integer,
token_contract -> Binary,
max_total_approval -> Binary,
}
}
diesel::table! {
evm_token_approval_grant_target (id) {
id -> Integer,
grant_id -> Integer,
address -> Binary,
}
}
diesel::table! {
evm_token_approval_log (id) {
id -> Integer,
grant_id -> Integer,
client_id -> Integer,
wallet_id -> Integer,
chain_id -> Integer,
token_contract -> Binary,
spender_address -> Binary,
value -> Binary,
created_at -> Integer,
}
}
diesel::table! {
evm_token_transfer_grant (id) {
id -> Integer,
basic_grant_id -> Integer,
token_contract -> Binary,
}
}
diesel::table! {
evm_token_transfer_grant_target (id) {
id -> Integer,
grant_id -> Integer,
address -> Binary,
}
}
diesel::table! {
evm_token_transfer_log (id) {
id -> Integer,
grant_id -> Integer,
client_id -> Integer,
wallet_id -> Integer,
chain_id -> Integer,
token_contract -> Binary,
recipient_address -> Binary,
value -> Binary,
created_at -> Integer,
}
}
diesel::table! {
evm_token_transfer_volume_limit (id) {
id -> Integer,
grant_id -> Integer,
window_secs -> Integer,
max_volume -> Binary,
}
}
diesel::table! {
evm_unknown_call_grant (id) {
id -> Integer,
basic_grant_id -> Integer,
contract -> Binary,
selector -> Nullable<Binary>,
}
}
diesel::table! {
evm_unknown_call_log (id) {
id -> Integer,
grant_id -> Integer,
client_id -> Integer,
wallet_id -> Integer,
chain_id -> Integer,
contract -> Binary,
selector -> Nullable<Binary>,
call_data -> Nullable<Binary>,
created_at -> Integer,
}
}
diesel::table! {
evm_wallet (id) {
id -> Integer,
address -> Binary,
aead_encrypted_id -> Integer,
created_at -> Integer,
}
}
diesel::table! { diesel::table! {
program_client (id) { program_client (id) {
id -> Integer, id -> Integer,
@@ -66,10 +222,49 @@ diesel::table! {
diesel::joinable!(aead_encrypted -> root_key_history (associated_root_key_id)); diesel::joinable!(aead_encrypted -> root_key_history (associated_root_key_id));
diesel::joinable!(arbiter_settings -> root_key_history (root_key_id)); diesel::joinable!(arbiter_settings -> root_key_history (root_key_id));
diesel::joinable!(arbiter_settings -> tls_history (tls_id)); diesel::joinable!(arbiter_settings -> tls_history (tls_id));
diesel::joinable!(evm_basic_grant -> evm_wallet (wallet_id));
diesel::joinable!(evm_basic_grant -> program_client (client_id));
diesel::joinable!(evm_ether_transfer_grant -> evm_basic_grant (basic_grant_id));
diesel::joinable!(evm_ether_transfer_grant_target -> evm_ether_transfer_grant (grant_id));
diesel::joinable!(evm_ether_transfer_log -> evm_ether_transfer_grant (grant_id));
diesel::joinable!(evm_ether_transfer_log -> evm_wallet (wallet_id));
diesel::joinable!(evm_ether_transfer_log -> program_client (client_id));
diesel::joinable!(evm_ether_transfer_volume_limit -> evm_ether_transfer_grant (grant_id));
diesel::joinable!(evm_token_approval_grant -> evm_basic_grant (basic_grant_id));
diesel::joinable!(evm_token_approval_grant_target -> evm_token_approval_grant (grant_id));
diesel::joinable!(evm_token_approval_log -> evm_token_approval_grant (grant_id));
diesel::joinable!(evm_token_approval_log -> evm_wallet (wallet_id));
diesel::joinable!(evm_token_approval_log -> program_client (client_id));
diesel::joinable!(evm_token_transfer_grant -> evm_basic_grant (basic_grant_id));
diesel::joinable!(evm_token_transfer_grant_target -> evm_token_transfer_grant (grant_id));
diesel::joinable!(evm_token_transfer_log -> evm_token_transfer_grant (grant_id));
diesel::joinable!(evm_token_transfer_log -> evm_wallet (wallet_id));
diesel::joinable!(evm_token_transfer_log -> program_client (client_id));
diesel::joinable!(evm_token_transfer_volume_limit -> evm_token_transfer_grant (grant_id));
diesel::joinable!(evm_unknown_call_grant -> evm_basic_grant (basic_grant_id));
diesel::joinable!(evm_unknown_call_log -> evm_unknown_call_grant (grant_id));
diesel::joinable!(evm_unknown_call_log -> evm_wallet (wallet_id));
diesel::joinable!(evm_unknown_call_log -> program_client (client_id));
diesel::joinable!(evm_wallet -> aead_encrypted (aead_encrypted_id));
diesel::allow_tables_to_appear_in_same_query!( diesel::allow_tables_to_appear_in_same_query!(
aead_encrypted, aead_encrypted,
arbiter_settings, arbiter_settings,
evm_basic_grant,
evm_ether_transfer_grant,
evm_ether_transfer_grant_target,
evm_ether_transfer_log,
evm_ether_transfer_volume_limit,
evm_token_approval_grant,
evm_token_approval_grant_target,
evm_token_approval_log,
evm_token_transfer_grant,
evm_token_transfer_grant_target,
evm_token_transfer_log,
evm_token_transfer_volume_limit,
evm_unknown_call_grant,
evm_unknown_call_log,
evm_wallet,
program_client, program_client,
root_key_history, root_key_history,
tls_history, tls_history,

View File

@@ -0,0 +1,132 @@
pub mod safe_signer;
use alloy::{
consensus::TxEip1559,
primitives::TxKind,
};
use diesel::insert_into;
use diesel_async::{AsyncConnection, RunQueryDsl};
use crate::{
db::{
self,
models::{EvmBasicGrant, NewEvmBasicGrant, SqliteTimestamp}, schema,
},
evm::policies::{
EvalContext, FullGrant, Policy, SpecificMeaning, ether_transfer::EtherTransfer
},
};
pub mod policies;
mod utils;
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
pub enum AnalyzeError {
#[error("Engine doesn't support granting permissions for contract creation")]
#[diagnostic(code(arbiter_server::evm::analyze_error::contract_creation_not_supported))]
ContractCreationNotSupported,
#[error("Unsupported transaction type")]
#[diagnostic(code(arbiter_server::evm::analyze_error::unsupported_transaction_type))]
UnsupportedTransactionType,
}
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
pub enum CreationError {
#[error("Database connection pool error")]
#[diagnostic(code(arbiter_server::evm::creation_error::database_error))]
Pool(#[from] db::PoolError),
#[error("Database returned error")]
#[diagnostic(code(arbiter_server::evm::creation_error::database_error))]
Database(#[from] diesel::result::Error),
}
// 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,
}
impl Engine {
pub fn new(db: db::DatabasePool) -> Self {
Self { db }
}
pub async fn create_grant<P: Policy>(
&self,
client_id: i32,
full_grant: FullGrant<P::Grant>,
) -> Result<i32, CreationError> {
let mut conn = self.db.get().await?;
let id = conn.transaction(|conn| {
Box::pin(async move {
use schema::evm_basic_grant;
let basic_grant: EvmBasicGrant = insert_into(evm_basic_grant::table)
.values(&NewEvmBasicGrant {
wallet_id: full_grant.basic.wallet_id,
chain_id: full_grant.basic.chain as i32,
client_id: client_id,
valid_from: full_grant.basic.valid_from.map(SqliteTimestamp),
valid_until: full_grant.basic.valid_until.map(SqliteTimestamp),
max_gas_fee_per_gas: full_grant
.basic
.max_gas_fee_per_gas
.map(|fee| utils::u256_to_bytes(fee).to_vec()),
max_priority_fee_per_gas: full_grant
.basic
.max_priority_fee_per_gas
.map(|fee| utils::u256_to_bytes(fee).to_vec()),
rate_limit_count: full_grant
.basic
.rate_limit
.as_ref()
.map(|rl| rl.count as i32),
rate_limit_window_secs: full_grant
.basic
.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?;
P::create_grant(&basic_grant, &full_grant.specific, conn).await
})
})
.await?;
Ok(id)
}
async fn perform_transaction<P: Policy>(&self, _context: EvalContext, _meaning: &P::Meaning) {
}
pub async fn analyze_transaction(
&self,
wallet_id: i32,
client_id: i32,
transaction: TxEip1559,
) -> Result<SpecificMeaning, AnalyzeError> {
let TxKind::Call(to) = transaction.to else {
return Err(AnalyzeError::ContractCreationNotSupported);
};
let context = policies::EvalContext {
wallet_id,
client_id,
chain: transaction.chain_id,
to: to,
value: transaction.value,
calldata: transaction.input.clone(),
};
if let Some(meaning) = EtherTransfer::analyze(&context) {
return Ok(SpecificMeaning::EtherTransfer(meaning));
}
Err(AnalyzeError::UnsupportedTransactionType)
}
}

View File

@@ -0,0 +1,129 @@
use std::fmt::Display;
use alloy::primitives::{Address, Bytes, ChainId, U256};
use chrono::{DateTime, Duration, Utc};
use diesel::{result::QueryResult, sqlite::Sqlite};
use diesel_async::AsyncConnection;
use miette::Diagnostic;
use thiserror::Error;
use crate::db::models;
pub mod ether_transfer;
pub struct EvalContext {
// Which wallet is this transaction for
pub client_id: i32,
pub wallet_id: i32,
// The transaction data
pub chain: ChainId,
pub to: Address,
pub value: U256,
pub calldata: Bytes,
}
#[derive(Debug, Error, Diagnostic)]
pub enum EvalViolation {
#[error("This grant doesn't allow transactions to the target address {target}")]
#[diagnostic(code(arbiter_server::evm::eval_violation::invalid_target))]
InvalidTarget { target: Address },
#[error("Gas limit exceeded for this grant")]
#[diagnostic(code(arbiter_server::evm::eval_violation::gas_limit_exceeded))]
GasLimitExceeded {
max_gas_fee_per_gas: Option<U256>,
max_priority_fee_per_gas: Option<U256>,
},
#[error("Rate limit exceeded for this grant")]
#[diagnostic(code(arbiter_server::evm::eval_violation::rate_limit_exceeded))]
RateLimitExceeded,
#[error("Transaction exceeds volumetric limits of the grant")]
#[diagnostic(code(arbiter_server::evm::eval_violation::volumetric_limit_exceeded))]
VolumetricLimitExceeded,
#[error("Transaction is outside of the grant's validity period")]
#[diagnostic(code(arbiter_server::evm::eval_violation::invalid_time))]
InvalidTime,
}
pub type DatabaseID = i32;
pub struct GrantMetadata {
pub basic_grant_id: DatabaseID,
pub policy_grant_id: DatabaseID,
}
pub trait Policy: Sized {
type Grant: Send + 'static + Into<SpecificGrant>;
type Meaning: Display + Send + 'static + Into<SpecificMeaning>;
fn analyze(context: &EvalContext) -> Option<Self::Meaning>;
// Evaluate whether a transaction with the given meaning complies with the provided grant, and return any violations if not
// Empty vector means transaction is compliant with the grant
fn evaluate(
meaning: &Self::Meaning,
grant: &Self::Grant,
meta: &GrantMetadata,
db: &mut impl AsyncConnection<Backend = Sqlite>,
) -> impl Future<Output = QueryResult<Vec<EvalViolation>>> + Send;
// Create a new grant in the database based on the provided grant details, and return its ID
fn create_grant(
basic: &models::EvmBasicGrant,
grant: &Self::Grant,
conn: &mut impl AsyncConnection<Backend = Sqlite>,
) -> impl std::future::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
fn try_find_grant(
context: &EvalContext,
conn: &mut impl AsyncConnection<Backend = Sqlite>,
) -> impl Future<Output = QueryResult<Option<(Self::Grant, GrantMetadata)>>>;
// Records, updates or deletes rate limits
// In other words, records grant-specific things after transaction is executed
fn record_transaction(
context: &EvalContext,
grant: &GrantMetadata,
conn: &mut impl AsyncConnection<Backend = Sqlite>,
) -> impl Future<Output = QueryResult<()>>;
}
// Classification of what transaction does
pub enum SpecificMeaning {
EtherTransfer(ether_transfer::Meaning),
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct TransactionRateLimit {
pub count: u32,
pub window: Duration,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct BasicGrant {
pub wallet_id: i32,
pub chain: ChainId,
pub valid_from: Option<DateTime<Utc>>,
pub valid_until: Option<DateTime<Utc>>,
pub max_gas_fee_per_gas: Option<U256>,
pub max_priority_fee_per_gas: Option<U256>,
pub rate_limit: Option<TransactionRateLimit>,
}
pub enum SpecificGrant {
EtherTransfer(ether_transfer::Grant),
}
pub struct FullGrant<PolicyGrant> {
pub basic: BasicGrant,
pub specific: PolicyGrant,
}

View File

@@ -0,0 +1,310 @@
use std::{fmt::Display, time::Duration};
use alloy::primitives::{Address, U256};
use chrono::{DateTime, Utc};
use diesel::dsl::insert_into;
use diesel::sqlite::Sqlite;
use diesel::{ExpressionMethods, JoinOnDsl, prelude::*};
use diesel_async::{AsyncConnection, RunQueryDsl};
use crate::db::models::{
EvmEtherTransferGrant, EvmEtherTransferGrantTarget, EvmEtherTransferVolumeLimit, SqliteTimestamp,
};
use crate::evm::policies::{GrantMetadata, SpecificGrant, SpecificMeaning};
use crate::{
db::{
models::{
self, NewEvmEtherTransferGrant, NewEvmEtherTransferGrantTarget,
NewEvmEtherTransferVolumeLimit,
},
schema::{
evm_ether_transfer_grant, evm_ether_transfer_grant_target,
evm_ether_transfer_volume_limit,
},
},
evm::{policies::Policy, utils},
};
use super::{DatabaseID, EvalContext, EvalViolation};
// Plain ether transfer
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct Meaning {
to: Address,
value: U256,
}
impl Display for Meaning {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"Ether transfer of {} to {}",
self.value,
self.to.to_string()
)
}
}
impl Into<SpecificMeaning> for Meaning {
fn into(self) -> SpecificMeaning {
SpecificMeaning::EtherTransfer(self)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct VolumeLimit {
window: Duration,
max_volume: U256,
}
// A grant for ether transfers, which can be scoped to specific target addresses and volume limits
pub struct Grant {
target: Vec<Address>,
limits: Vec<VolumeLimit>,
}
impl Into<SpecificGrant> for Grant {
fn into(self) -> SpecificGrant {
SpecificGrant::EtherTransfer(self)
}
}
async fn query_relevant_past_transaction(
grant_id: i32,
longest_window: Duration,
db: &mut impl AsyncConnection<Backend = Sqlite>,
) -> QueryResult<Vec<(U256, DateTime<Utc>)>> {
use crate::db::schema::evm_ether_transfer_log;
let past_transactions: Vec<(Vec<u8>, SqliteTimestamp)> =
evm_ether_transfer_log::table
.filter(evm_ether_transfer_log::grant_id.eq(grant_id))
.filter(
evm_ether_transfer_log::created_at
.ge(SqliteTimestamp(chrono::Utc::now() - longest_window)),
)
.select((
evm_ether_transfer_log::value,
evm_ether_transfer_log::created_at,
))
.load(db)
.await?;
let past_transaction: Vec<(U256, DateTime<Utc>)> = past_transactions
.into_iter()
.filter_map(|(value_bytes, timestamp)| {
let value = utils::bytes_to_u256(&value_bytes)?;
Some((value, timestamp.0))
})
.collect();
Ok(past_transaction)
}
async fn check_rate_limits(
grant: &Grant,
meta: &GrantMetadata,
db: &mut impl AsyncConnection<Backend = Sqlite>,
) -> QueryResult<Vec<EvalViolation>> {
let mut violations = Vec::new();
// This has double meaning: checks for limit presence, and finds biggest window
// to extract all needed historical transactions in one go later
let longest_window = grant.limits.iter().map(|limit| limit.window).max();
if let Some(longest_window) = longest_window {
let _past_transaction = query_relevant_past_transaction(meta.policy_grant_id, longest_window, db).await?;
for limit in &grant.limits {
let window_start = chrono::Utc::now() - limit.window;
let cumulative_volume: U256 = _past_transaction
.iter()
.filter(|(_, timestamp)| timestamp >= &window_start)
.fold(U256::default(), |acc, (value, _)| acc + *value);
if cumulative_volume > limit.max_volume {
violations.push(EvalViolation::VolumetricLimitExceeded);
}
}
// TODO: Implement actual rate limit checking logic
}
Ok(violations)
}
pub struct EtherTransfer;
impl Policy for EtherTransfer {
type Grant = Grant;
type Meaning = Meaning;
fn analyze(context: &EvalContext) -> Option<Self::Meaning> {
if !context.calldata.is_empty() {
return None;
}
Some(Meaning {
to: context.to,
value: context.value,
})
}
async fn evaluate(
meaning: &Self::Meaning,
grant: &Self::Grant,
meta: &GrantMetadata,
db: &mut impl AsyncConnection<Backend = Sqlite>,
) -> QueryResult<Vec<EvalViolation>> {
let mut violations = Vec::new();
// Check if the target address is within the grant's allowed targets
if !grant.target.contains(&meaning.to) {
violations.push(EvalViolation::InvalidTarget { target: meaning.to });
}
let rate_violations = check_rate_limits(grant, meta, db).await?;
violations.extend(rate_violations);
Ok(violations)
}
async fn create_grant(
basic: &models::EvmBasicGrant,
grant: &Self::Grant,
conn: &mut impl AsyncConnection<Backend = Sqlite>,
) -> diesel::result::QueryResult<DatabaseID> {
let grant_id: i32 = insert_into(evm_ether_transfer_grant::table)
.values(&NewEvmEtherTransferGrant {
basic_grant_id: basic.id,
})
.returning(evm_ether_transfer_grant::id)
.get_result(conn)
.await?;
for target in &grant.target {
insert_into(evm_ether_transfer_grant_target::table)
.values(NewEvmEtherTransferGrantTarget {
grant_id,
address: target.to_vec(),
})
.execute(conn)
.await?;
}
for limit in &grant.limits {
insert_into(evm_ether_transfer_volume_limit::table)
.values(NewEvmEtherTransferVolumeLimit {
grant_id,
window_secs: limit.window.as_secs() as i32,
max_volume: utils::u256_to_bytes(limit.max_volume).to_vec(),
})
.execute(conn)
.await?;
}
Ok(grant_id)
}
async fn try_find_grant(
context: &EvalContext,
conn: &mut impl AsyncConnection<Backend = Sqlite>,
) -> diesel::result::QueryResult<Option<(Self::Grant, GrantMetadata)>> {
use crate::db::schema::{
evm_basic_grant, evm_ether_transfer_grant, evm_ether_transfer_grant_target,
};
let target_bytes = context.to.to_vec();
// Find a grant where:
// 1. The basic grant's wallet_id and client_id match the context
// 2. Any of the grant's targets match the context's `to` address
let grant: Option<EvmEtherTransferGrant> = evm_ether_transfer_grant::table
.inner_join(
evm_basic_grant::table
.on(evm_ether_transfer_grant::basic_grant_id.eq(evm_basic_grant::id)),
)
.inner_join(
evm_ether_transfer_grant_target::table
.on(evm_ether_transfer_grant::id.eq(evm_ether_transfer_grant_target::grant_id)),
)
.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))
.select(EvmEtherTransferGrant::as_select())
.first::<EvmEtherTransferGrant>(conn)
.await
.optional()?;
let Some(grant) = grant else {
return Ok(None);
};
use crate::db::schema::evm_ether_transfer_volume_limit;
// Load grant targets
let target_bytes: Vec<EvmEtherTransferGrantTarget> = evm_ether_transfer_grant_target::table
.select(EvmEtherTransferGrantTarget::as_select())
.filter(evm_ether_transfer_grant_target::grant_id.eq(grant.id))
.load(conn)
.await?;
// Load volume limits
let limit_rows: Vec<EvmEtherTransferVolumeLimit> = evm_ether_transfer_volume_limit::table
.filter(evm_ether_transfer_volume_limit::grant_id.eq(grant.id))
.select(EvmEtherTransferVolumeLimit::as_select())
.load(conn)
.await?;
// Convert bytes back to Address
let targets: Vec<Address> = target_bytes
.into_iter()
.filter_map(|target| {
// TODO: Handle invalid addresses more gracefully
let arr: [u8; 20] = target.address.try_into().ok()?;
Some(Address::from(arr))
})
.collect();
// Convert database rows to VolumeLimit
let limits: Vec<VolumeLimit> = limit_rows
.into_iter()
.filter_map(|limit| {
// TODO: Handle invalid volumes more gracefully
let max_volume = utils::bytes_to_u256(&limit.max_volume)?;
Some(VolumeLimit {
window: Duration::from_secs(limit.window_secs as u64),
max_volume,
})
})
.collect();
let domain_grant = Grant {
target: targets,
limits,
};
Ok(Some((
domain_grant,
GrantMetadata {
basic_grant_id: grant.basic_grant_id,
policy_grant_id: grant.id,
},
)))
}
async fn record_transaction(
context: &EvalContext,
grant: &GrantMetadata,
conn: &mut impl AsyncConnection<Backend = Sqlite>,
) -> diesel::result::QueryResult<()> {
use crate::db::schema::evm_ether_transfer_log;
insert_into(evm_ether_transfer_log::table)
.values(models::NewEvmEtherTransferLog {
grant_id: grant.policy_grant_id,
value: utils::u256_to_bytes(context.value).to_vec(),
client_id: context.client_id,
wallet_id: context.wallet_id,
chain_id: context.chain as i32,
recipient_address: context.to.to_vec(),
})
.execute(conn)
.await?;
Ok(())
}
}

View File

@@ -0,0 +1,184 @@
use std::sync::Mutex;
use alloy::{
consensus::SignableTransaction,
network::{TxSigner, TxSignerSync},
primitives::{Address, ChainId, Signature, B256},
signers::{Error, Result, Signer, SignerSync, utils::secret_key_to_address},
};
use async_trait::async_trait;
use k256::ecdsa::{self, signature::hazmat::PrehashSigner, RecoveryId, SigningKey};
use memsafe::MemSafe;
/// An Ethereum signer that stores its secp256k1 secret key inside a
/// hardware-protected [`MemSafe`] cell.
///
/// The underlying memory page is kept non-readable/non-writable at rest.
/// Access is temporarily elevated only for the duration of each signing
/// operation, then immediately revoked.
///
/// Because [`MemSafe::read`] requires `&mut self` while the [`Signer`] trait
/// requires `&self`, the cell is wrapped in a [`Mutex`].
pub struct SafeSigner {
key: Mutex<MemSafe<SigningKey>>,
address: Address,
chain_id: Option<ChainId>,
}
impl std::fmt::Debug for SafeSigner {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SafeSigner")
.field("address", &self.address)
.field("chain_id", &self.chain_id)
.finish()
}
}
/// Generates a secp256k1 secret key directly inside a [`MemSafe`] cell.
///
/// Random bytes are written in-place into protected memory, then validated
/// as a legal scalar on the secp256k1 curve (the scalar must be in
/// `[1, n)` where `n` is the curve order — roughly 1-in-2^128 chance of
/// rejection, but we retry to be correct).
///
/// Returns the protected key bytes and the derived Ethereum address.
pub fn generate(rng: &mut impl rand::Rng) -> (MemSafe<[u8; 32]>, Address) {
loop {
let mut cell = MemSafe::new([0u8; 32]).expect("MemSafe allocation");
{
let mut w = cell.write().expect("MemSafe write");
rng.fill_bytes(w.as_mut());
}
let reader = cell.read().expect("MemSafe read");
if let Ok(sk) = SigningKey::from_slice(reader.as_ref()) {
let address = secret_key_to_address(&sk);
drop(reader);
return (cell, address);
}
}
}
impl SafeSigner {
/// Creates a new `SafeSigner` by moving the signing key into a protected
/// memory region.
pub fn new(key: SigningKey) -> Result<Self> {
let address = secret_key_to_address(&key);
let cell = MemSafe::new(key).map_err(Error::other)?;
Ok(Self {
key: Mutex::new(cell),
address,
chain_id: None,
})
}
fn sign_hash_inner(&self, hash: &B256) -> Result<Signature> {
let mut cell = self.key.lock().expect("SafeSigner mutex poisoned");
let reader = cell.read().map_err(Error::other)?;
let sig: (ecdsa::Signature, RecoveryId) = reader.sign_prehash(hash.as_ref())?;
Ok(sig.into())
}
fn sign_tx_inner(
&self,
tx: &mut dyn SignableTransaction<Signature>,
) -> Result<Signature> {
if let Some(chain_id) = self.chain_id {
if !tx.set_chain_id_checked(chain_id) {
return Err(Error::TransactionChainIdMismatch {
signer: chain_id,
tx: tx.chain_id().unwrap(),
});
}
}
self.sign_hash_inner(&tx.signature_hash()).map_err(Error::other)
}
}
#[async_trait]
impl Signer for SafeSigner {
#[inline]
async fn sign_hash(&self, hash: &B256) -> Result<Signature> {
self.sign_hash_inner(hash)
}
#[inline]
fn address(&self) -> Address {
self.address
}
#[inline]
fn chain_id(&self) -> Option<ChainId> {
self.chain_id
}
#[inline]
fn set_chain_id(&mut self, chain_id: Option<ChainId>) {
self.chain_id = chain_id;
}
}
impl SignerSync for SafeSigner {
#[inline]
fn sign_hash_sync(&self, hash: &B256) -> Result<Signature> {
self.sign_hash_inner(hash)
}
#[inline]
fn chain_id_sync(&self) -> Option<ChainId> {
self.chain_id
}
}
#[async_trait]
impl TxSigner<Signature> for SafeSigner {
fn address(&self) -> Address {
self.address
}
async fn sign_transaction(
&self,
tx: &mut dyn SignableTransaction<Signature>,
) -> Result<Signature> {
self.sign_tx_inner(tx)
}
}
impl TxSignerSync<Signature> for SafeSigner {
fn address(&self) -> Address {
self.address
}
fn sign_transaction_sync(
&self,
tx: &mut dyn SignableTransaction<Signature>,
) -> Result<Signature> {
self.sign_tx_inner(tx)
}
}
#[cfg(test)]
mod tests {
use super::*;
use alloy::signers::local::PrivateKeySigner;
#[test]
fn sign_and_recover() {
let pk = PrivateKeySigner::random();
let key = pk.into_credential();
let signer = SafeSigner::new(key).unwrap();
let message = b"hello arbiter";
let sig = signer.sign_message_sync(message).unwrap();
let recovered = sig.recover_address_from_msg(message).unwrap();
assert_eq!(recovered, Signer::address(&signer));
}
#[test]
fn chain_id_roundtrip() {
let pk = PrivateKeySigner::random();
let key = pk.into_credential();
let mut signer = SafeSigner::new(key).unwrap();
assert_eq!(Signer::chain_id(&signer), None);
signer.set_chain_id(Some(1337));
assert_eq!(Signer::chain_id(&signer), Some(1337));
}
}

View File

@@ -0,0 +1,9 @@
use alloy::primitives::U256;
pub fn u256_to_bytes(value: U256) -> [u8; 32] {
value.to_le_bytes()
}
pub fn bytes_to_u256(bytes: &[u8]) -> Option<U256> {
let bytes: [u8; 32] = bytes.try_into().ok()?;
Some(U256::from_le_bytes(bytes))
}

View File

@@ -24,6 +24,7 @@ use crate::{
pub mod actors; pub mod actors;
pub mod context; pub mod context;
pub mod db; pub mod db;
pub mod evm;
const DEFAULT_CHANNEL_SIZE: usize = 1000; const DEFAULT_CHANNEL_SIZE: usize = 1000;