diff --git a/mise.lock b/mise.lock index 3cd025e..9cf1bee 100644 --- a/mise.lock +++ b/mise.lock @@ -8,10 +8,18 @@ backend = "aqua:ast-grep/ast-grep" checksum = "sha256:5c830eae8456569e2f7212434ed9c238f58dca412d76045418ed6d394a755836" url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-aarch64-unknown-linux-gnu.zip" +[tools.ast-grep."platforms.linux-arm64-musl"] +checksum = "sha256:5c830eae8456569e2f7212434ed9c238f58dca412d76045418ed6d394a755836" +url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-aarch64-unknown-linux-gnu.zip" + [tools.ast-grep."platforms.linux-x64"] checksum = "sha256:e825a05603f0bcc4cd9076c4cc8c9abd6d008b7cd07d9aa3cc323ba4b8606651" url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-x86_64-unknown-linux-gnu.zip" +[tools.ast-grep."platforms.linux-x64-musl"] +checksum = "sha256:e825a05603f0bcc4cd9076c4cc8c9abd6d008b7cd07d9aa3cc323ba4b8606651" +url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-x86_64-unknown-linux-gnu.zip" + [tools.ast-grep."platforms.macos-arm64"] checksum = "sha256:fc300d5293b1c770a5aece03a8a193b92e71e87cec726c28096990691a582620" url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-aarch64-apple-darwin.zip" @@ -32,10 +40,6 @@ backend = "cargo:cargo-audit" version = "0.13.9" backend = "cargo:cargo-edit" -[[tools."cargo:cargo-features"]] -version = "1.0.0" -backend = "cargo:cargo-features" - [[tools."cargo:cargo-features-manager"]] version = "0.11.1" backend = "cargo:cargo-features-manager" @@ -49,21 +53,13 @@ version = "0.9.126" backend = "cargo:cargo-nextest" [[tools."cargo:cargo-shear"]] -version = "1.9.1" +version = "1.11.2" backend = "cargo:cargo-shear" [[tools."cargo:cargo-vet"]] version = "0.10.2" backend = "cargo:cargo-vet" -[[tools."cargo:diesel-cli"]] -version = "2.3.6" -backend = "cargo:diesel-cli" - -[tools."cargo:diesel-cli".options] -default-features = "false" -features = "sqlite,sqlite-bundled" - [[tools."cargo:diesel_cli"]] version = "2.3.6" backend = "cargo:diesel_cli" @@ -72,10 +68,6 @@ backend = "cargo:diesel_cli" default-features = "false" features = "sqlite,sqlite-bundled" -[[tools."cargo:rinf_cli"]] -version = "8.9.1" -backend = "cargo:rinf_cli" - [[tools.flutter]] version = "3.38.9-stable" backend = "asdf:flutter" @@ -88,10 +80,18 @@ backend = "aqua:protocolbuffers/protobuf/protoc" checksum = "sha256:2594ff4fcae8cb57310d394d0961b236190ad9c5efbfdf1f597ea471d424fe79" url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-linux-aarch_64.zip" +[tools.protoc."platforms.linux-arm64-musl"] +checksum = "sha256:2594ff4fcae8cb57310d394d0961b236190ad9c5efbfdf1f597ea471d424fe79" +url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-linux-aarch_64.zip" + [tools.protoc."platforms.linux-x64"] checksum = "sha256:48785a926e73ffa3f68e2f22b14e7b849620c7a1d36809ac9249a5495e280323" url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-linux-x86_64.zip" +[tools.protoc."platforms.linux-x64-musl"] +checksum = "sha256:48785a926e73ffa3f68e2f22b14e7b849620c7a1d36809ac9249a5495e280323" +url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-linux-x86_64.zip" + [tools.protoc."platforms.macos-arm64"] checksum = "sha256:b9576b5fa1a1ef3fe13a8c91d9d8204b46545759bea5ae155cd6ba2ea4cdaeed" url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-osx-aarch_64.zip" @@ -109,24 +109,32 @@ version = "3.14.3" backend = "core:python" [tools.python."platforms.linux-arm64"] -checksum = "sha256:be0f4dc2932f762292b27d46ea7d3e8e66ddf3969a5eb0254a229015ed402625" -url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260303/cpython-3.14.3+20260303-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz" +checksum = "sha256:53700338695e402a1a1fe22be4a41fbdacc70e22bb308a48eca8ed67cb7992be" +url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz" + +[tools.python."platforms.linux-arm64-musl"] +checksum = "sha256:53700338695e402a1a1fe22be4a41fbdacc70e22bb308a48eca8ed67cb7992be" +url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz" [tools.python."platforms.linux-x64"] -checksum = "sha256:0a73413f89efd417871876c9accaab28a9d1e3cd6358fbfff171a38ec99302f0" -url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260303/cpython-3.14.3+20260303-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz" +checksum = "sha256:d7a9f970914bb4c88756fe3bdcc186d4feb90e9500e54f1db47dae4dc9687e39" +url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz" + +[tools.python."platforms.linux-x64-musl"] +checksum = "sha256:d7a9f970914bb4c88756fe3bdcc186d4feb90e9500e54f1db47dae4dc9687e39" +url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz" [tools.python."platforms.macos-arm64"] -checksum = "sha256:4703cdf18b26798fde7b49b6b66149674c25f97127be6a10dbcf29309bdcdcdb" -url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260303/cpython-3.14.3+20260303-aarch64-apple-darwin-install_only_stripped.tar.gz" +checksum = "sha256:c43aecde4a663aebff99b9b83da0efec506479f1c3f98331442f33d2c43501f9" +url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-aarch64-apple-darwin-install_only_stripped.tar.gz" [tools.python."platforms.macos-x64"] -checksum = "sha256:76f1cc26e3d262eae8ca546a93e8bded10cf0323613f7e246fea2e10a8115eb7" -url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260303/cpython-3.14.3+20260303-x86_64-apple-darwin-install_only_stripped.tar.gz" +checksum = "sha256:9ab41dbc2f100a2a45d1833b9c11165f51051c558b5213eda9a9731d5948a0c0" +url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-x86_64-apple-darwin-install_only_stripped.tar.gz" [tools.python."platforms.windows-x64"] -checksum = "sha256:950c5f21a015c1bdd1337f233456df2470fab71e4d794407d27a84cb8b9909a0" -url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260303/cpython-3.14.3+20260303-x86_64-pc-windows-msvc-install_only_stripped.tar.gz" +checksum = "sha256:bbe19034b35b0267176a7442575ae7dc6343480fd4d35598cb7700173d431e09" +url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-x86_64-pc-windows-msvc-install_only_stripped.tar.gz" [[tools.rust]] version = "1.93.0" diff --git a/protobufs/user_agent/evm.proto b/protobufs/user_agent/evm.proto index 5668d4d..459c152 100644 --- a/protobufs/user_agent/evm.proto +++ b/protobufs/user_agent/evm.proto @@ -5,6 +5,11 @@ package arbiter.user_agent.evm; import "evm.proto"; import "google/protobuf/empty.proto"; +message SignTransactionRequest { + int32 client_id = 1; + arbiter.evm.EvmSignTransactionRequest request = 2; +} + message Request { oneof payload { google.protobuf.Empty wallet_create = 1; @@ -12,6 +17,7 @@ message Request { arbiter.evm.EvmGrantCreateRequest grant_create = 3; arbiter.evm.EvmGrantDeleteRequest grant_delete = 4; arbiter.evm.EvmGrantListRequest grant_list = 5; + SignTransactionRequest sign_transaction = 6; } } @@ -22,5 +28,6 @@ message Response { arbiter.evm.EvmGrantCreateResponse grant_create = 3; arbiter.evm.EvmGrantDeleteResponse grant_delete = 4; arbiter.evm.EvmGrantListResponse grant_list = 5; + arbiter.evm.EvmSignTransactionResponse sign_transaction = 6; } } diff --git a/server/crates/arbiter-client/src/transport.rs b/server/crates/arbiter-client/src/transport.rs index d56a9f8..7332e89 100644 --- a/server/crates/arbiter-client/src/transport.rs +++ b/server/crates/arbiter-client/src/transport.rs @@ -1,6 +1,4 @@ -use arbiter_proto::proto::{ - client::{ClientRequest, ClientResponse}, -}; +use arbiter_proto::proto::client::{ClientRequest, ClientResponse}; use std::sync::atomic::{AtomicI32, Ordering}; use tokio::sync::mpsc; @@ -36,9 +34,7 @@ impl ClientTransport { .map_err(|_| ClientSignError::ChannelClosed) } - pub(crate) async fn recv( - &mut self, - ) -> std::result::Result { + pub(crate) async fn recv(&mut self) -> std::result::Result { match self.receiver.message().await { Ok(Some(resp)) => Ok(resp), Ok(None) => Err(ClientSignError::ConnectionClosed), diff --git a/server/crates/arbiter-client/src/wallets/evm.rs b/server/crates/arbiter-client/src/wallets/evm.rs index 32ae735..4533793 100644 --- a/server/crates/arbiter-client/src/wallets/evm.rs +++ b/server/crates/arbiter-client/src/wallets/evm.rs @@ -8,7 +8,15 @@ use async_trait::async_trait; use std::sync::Arc; use tokio::sync::Mutex; -use crate::transport::ClientTransport; +use arbiter_proto::proto::{ + client::{ + ClientRequest, client_request::Payload as ClientRequestPayload, + client_response::Payload as ClientResponsePayload, + }, + evm::evm_sign_transaction_response::Result as EvmSignTransactionResult, +}; + +use crate::transport::{ClientTransport, next_request_id}; pub struct ArbiterEvmWallet { transport: Arc>, @@ -79,11 +87,61 @@ impl TxSigner for ArbiterEvmWallet { &self, tx: &mut dyn SignableTransaction, ) -> Result { - let _transport = self.transport.lock().await; self.validate_chain_id(tx)?; - Err(Error::other( - "transaction signing is not supported by current arbiter.client protocol", - )) + let mut transport = self.transport.lock().await; + let request_id = next_request_id(); + let rlp_transaction = tx.encoded_for_signing(); + + transport + .send(ClientRequest { + request_id, + payload: Some(ClientRequestPayload::EvmSignTransaction( + arbiter_proto::proto::evm::EvmSignTransactionRequest { + wallet_address: self.address.to_vec(), + rlp_transaction, + }, + )), + }) + .await + .map_err(|_| Error::other("failed to send evm sign transaction request"))?; + + let response = transport + .recv() + .await + .map_err(|_| Error::other("failed to receive evm sign transaction response"))?; + + if response.request_id != Some(request_id) { + return Err(Error::other( + "received mismatched response id for evm sign transaction", + )); + } + + let payload = response + .payload + .ok_or_else(|| Error::other("missing evm sign transaction response payload"))?; + + let ClientResponsePayload::EvmSignTransaction(response) = payload else { + return Err(Error::other( + "unexpected response payload for evm sign transaction request", + )); + }; + + let result = response + .result + .ok_or_else(|| Error::other("missing evm sign transaction result"))?; + + match result { + EvmSignTransactionResult::Signature(signature) => { + Signature::try_from(signature.as_slice()) + .map_err(|_| Error::other("invalid signature returned by server")) + } + EvmSignTransactionResult::EvalError(eval_error) => Err(Error::other(format!( + "transaction rejected by policy: {eval_error:?}" + ))), + EvmSignTransactionResult::Error(code) => Err(Error::other(format!( + "server failed to sign transaction with error code {code}" + ))), + } } } diff --git a/server/crates/arbiter-server/src/actors/client/auth.rs b/server/crates/arbiter-server/src/actors/client/auth.rs index 4fd53bf..181b974 100644 --- a/server/crates/arbiter-server/src/actors/client/auth.rs +++ b/server/crates/arbiter-server/src/actors/client/auth.rs @@ -1,5 +1,6 @@ use arbiter_proto::{ - ClientMetadata, format_challenge, transport::{Bi, expect_message} + ClientMetadata, format_challenge, + transport::{Bi, expect_message}, }; use chrono::Utc; use diesel::{ @@ -83,7 +84,6 @@ async fn get_client_and_nonce( })?; conn.exclusive_transaction(|conn| { - let pubkey_bytes = pubkey_bytes.clone(); Box::pin(async move { let Some((client_id, current_nonce)) = program_client::table .filter(program_client::public_key.eq(&pubkey_bytes)) @@ -290,7 +290,7 @@ where pub async fn authenticate( props: &mut ClientConnection, transport: &mut T, -) -> Result +) -> Result where T: Bi> + Send + ?Sized, { @@ -318,7 +318,6 @@ where }; sync_client_metadata(&props.db, info.id, &metadata).await?; - challenge_client(transport, pubkey, info.current_nonce).await?; transport @@ -329,5 +328,5 @@ where Error::Transport })?; - Ok(pubkey) + Ok(info.id) } diff --git a/server/crates/arbiter-server/src/actors/client/mod.rs b/server/crates/arbiter-server/src/actors/client/mod.rs index f60e90a..f747572 100644 --- a/server/crates/arbiter-server/src/actors/client/mod.rs +++ b/server/crates/arbiter-server/src/actors/client/mod.rs @@ -3,7 +3,7 @@ use kameo::actor::Spawn; use tracing::{error, info}; use crate::{ - actors::{GlobalActors, client::{ session::ClientSession}}, + actors::{GlobalActors, client::session::ClientSession}, db, }; @@ -20,7 +20,10 @@ pub struct ClientConnection { impl ClientConnection { pub fn new(db: db::DatabasePool, actors: GlobalActors) -> Self { - Self { db, actors } + Self { + db, + actors, + } } } @@ -32,8 +35,8 @@ where T: Bi> + Send + ?Sized, { match auth::authenticate(&mut props, transport).await { - Ok(_pubkey) => { - ClientSession::spawn(ClientSession::new(props)); + Ok(client_id) => { + ClientSession::spawn(ClientSession::new(props, client_id)); info!("Client authenticated, session started"); } Err(err) => { diff --git a/server/crates/arbiter-server/src/actors/client/session.rs b/server/crates/arbiter-server/src/actors/client/session.rs index 83e2e29..d243f00 100644 --- a/server/crates/arbiter-server/src/actors/client/session.rs +++ b/server/crates/arbiter-server/src/actors/client/session.rs @@ -1,21 +1,30 @@ +use ed25519_dalek::VerifyingKey; use kameo::{Actor, messages}; use tracing::error; +use alloy::{consensus::TxEip1559, primitives::Address, signers::Signature}; + use crate::{ actors::{ - GlobalActors, client::ClientConnection, flow_coordinator::RegisterClient, + GlobalActors, + client::ClientConnection, flow_coordinator::RegisterClient, + + evm::{ClientSignTransaction, SignTransactionError}, keyholder::KeyHolderState, + }, db, + evm::VetError, }; pub struct ClientSession { props: ClientConnection, + client_id: i32, } impl ClientSession { - pub(crate) fn new(props: ClientConnection) -> Self { - Self { props } + pub(crate) fn new(props: ClientConnection, client_id: i32) -> Self { + Self { props, client_id } } } @@ -35,6 +44,34 @@ impl ClientSession { Ok(vault_state) } + + #[message] + pub(crate) async fn handle_sign_transaction( + &mut self, + wallet_address: Address, + transaction: TxEip1559, + ) -> Result { + match self + .props + .actors + .evm + .ask(ClientSignTransaction { + client_id: self.client_id, + wallet_address, + transaction, + }) + .await + { + Ok(signature) => Ok(signature), + Err(kameo::error::SendError::HandlerError(SignTransactionError::Vet(vet_error))) => { + Err(SignTransactionRpcError::Vet(vet_error)) + } + Err(err) => { + error!(?err, "Failed to sign EVM transaction in client session"); + Err(SignTransactionRpcError::Internal) + } + } + } } impl Actor for ClientSession { @@ -59,7 +96,7 @@ impl Actor for ClientSession { impl ClientSession { pub fn new_test(db: db::DatabasePool, actors: GlobalActors) -> Self { let props = ClientConnection::new(db, actors); - Self { props } + Self { props, client_id: 0 } } } @@ -70,3 +107,12 @@ pub enum Error { #[error("Internal error")] Internal, } + +#[derive(Debug, thiserror::Error)] +pub enum SignTransactionRpcError { + #[error("Policy evaluation failed")] + Vet(#[from] VetError), + + #[error("Internal error")] + Internal, +} diff --git a/server/crates/arbiter-server/src/actors/keyholder/mod.rs b/server/crates/arbiter-server/src/actors/keyholder/mod.rs index 3a245af..fbf3d50 100644 --- a/server/crates/arbiter-server/src/actors/keyholder/mod.rs +++ b/server/crates/arbiter-server/src/actors/keyholder/mod.rs @@ -214,7 +214,6 @@ impl KeyHolder { let mut conn = self.db.get().await?; schema::root_key_history::table .filter(schema::root_key_history::id.eq(*root_key_history_id)) - .select(schema::root_key_history::data_encryption_nonce) .select(RootKeyHistory::as_select()) .first(&mut conn) .await? diff --git a/server/crates/arbiter-server/src/actors/user_agent/auth/state.rs b/server/crates/arbiter-server/src/actors/user_agent/auth/state.rs index c422589..eea0661 100644 --- a/server/crates/arbiter-server/src/actors/user_agent/auth/state.rs +++ b/server/crates/arbiter-server/src/actors/user_agent/auth/state.rs @@ -210,13 +210,16 @@ where } }; - if valid { - self.transport - .send(Ok(Outbound::AuthSuccess)) - .await - .map_err(|_| Error::Transport)?; + if !valid { + error!("Invalid challenge solution signature"); + return Err(Error::InvalidChallengeSolution); } + self.transport + .send(Ok(Outbound::AuthSuccess)) + .await + .map_err(|_| Error::Transport)?; + Ok(key.clone()) } } diff --git a/server/crates/arbiter-server/src/actors/user_agent/session/connection.rs b/server/crates/arbiter-server/src/actors/user_agent/session/connection.rs index 30b10ae..f295c73 100644 --- a/server/crates/arbiter-server/src/actors/user_agent/session/connection.rs +++ b/server/crates/arbiter-server/src/actors/user_agent/session/connection.rs @@ -1,6 +1,6 @@ use std::sync::Mutex; -use alloy::primitives::Address; +use alloy::{consensus::TxEip1559, primitives::Address, signers::Signature}; use chacha20poly1305::{AeadInPlace, XChaCha20Poly1305, XNonce, aead::KeyInit}; use diesel::sql_types::ops::Add; use diesel::{BoolExpressionMethods as _, ExpressionMethods as _, QueryDsl as _, SelectableHelper}; @@ -23,7 +23,8 @@ use crate::safe_cell::SafeCell; use crate::{ actors::{ evm::{ - Generate, ListWallets, UseragentCreateGrant, UseragentDeleteGrant, UseragentListGrants, + ClientSignTransaction, Generate, ListWallets, SignTransactionError as EvmSignError, + UseragentCreateGrant, UseragentDeleteGrant, UseragentListGrants, }, keyholder::{self, Bootstrap, TryUnseal}, user_agent::session::{ @@ -112,6 +113,15 @@ pub enum BootstrapError { General(#[from] super::Error), } +#[derive(Debug, Error)] +pub enum SignTransactionError { + #[error("Policy evaluation failed")] + Vet(#[from] crate::evm::VetError), + + #[error("Internal signing error")] + Internal, +} + #[messages] impl UserAgentSession { #[message] @@ -356,6 +366,35 @@ impl UserAgentSession { } } + #[message] + pub(crate) async fn handle_sign_transaction( + &mut self, + client_id: i32, + wallet_address: Address, + transaction: TxEip1559, + ) -> Result { + match self + .props + .actors + .evm + .ask(ClientSignTransaction { + client_id, + wallet_address, + transaction, + }) + .await + { + Ok(signature) => Ok(signature), + Err(SendError::HandlerError(EvmSignError::Vet(vet_error))) => { + Err(SignTransactionError::Vet(vet_error)) + } + Err(err) => { + error!(?err, "EVM sign transaction failed in user-agent session"); + Err(SignTransactionError::Internal) + } + } + } + #[message] pub(crate) async fn handle_grant_evm_wallet_access( &mut self, diff --git a/server/crates/arbiter-server/src/evm/mod.rs b/server/crates/arbiter-server/src/evm/mod.rs index 54bcb1e..c922202 100644 --- a/server/crates/arbiter-server/src/evm/mod.rs +++ b/server/crates/arbiter-server/src/evm/mod.rs @@ -32,7 +32,7 @@ mod utils; #[derive(Debug, thiserror::Error, miette::Diagnostic)] pub enum PolicyError { #[error("Database error")] - Error(#[from] crate::db::DatabaseError), + Database(#[from] crate::db::DatabaseError), #[error("Transaction violates policy: {0:?}")] #[diagnostic(code(arbiter_server::evm::policy_error::violation))] Violations(Vec), diff --git a/server/crates/arbiter-server/src/evm/policies/ether_transfer/mod.rs b/server/crates/arbiter-server/src/evm/policies/ether_transfer/mod.rs index b6c68c6..d419b59 100644 --- a/server/crates/arbiter-server/src/evm/policies/ether_transfer/mod.rs +++ b/server/crates/arbiter-server/src/evm/policies/ether_transfer/mod.rs @@ -36,8 +36,8 @@ use super::{DatabaseID, EvalContext, EvalViolation}; // Plain ether transfer #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub struct Meaning { - to: Address, - value: U256, + pub(crate) to: Address, + pub(crate) value: U256, } impl Display for Meaning { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { @@ -91,6 +91,7 @@ async fn query_relevant_past_transaction( async fn check_rate_limits( grant: &Grant, + current_transfer_value: U256, db: &mut impl AsyncConnection, ) -> QueryResult> { let mut violations = Vec::new(); @@ -99,12 +100,12 @@ 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.limit.window; - let cumulative_volume: U256 = past_transaction + let prospective_cumulative_volume: U256 = past_transaction .iter() .filter(|(_, timestamp)| timestamp >= &window_start) - .fold(U256::default(), |acc, (value, _)| acc + *value); + .fold(current_transfer_value, |acc, (value, _)| acc + *value); - if cumulative_volume > grant.settings.limit.max_volume { + if prospective_cumulative_volume > grant.settings.limit.max_volume { violations.push(EvalViolation::VolumetricLimitExceeded); } @@ -141,7 +142,7 @@ impl Policy for EtherTransfer { violations.push(EvalViolation::InvalidTarget { target: meaning.to }); } - let rate_violations = check_rate_limits(grant, db).await?; + let rate_violations = check_rate_limits(grant, meaning.value, db).await?; violations.extend(rate_violations); Ok(violations) diff --git a/server/crates/arbiter-server/src/evm/policies/ether_transfer/tests.rs b/server/crates/arbiter-server/src/evm/policies/ether_transfer/tests.rs index cba78b0..9ba48be 100644 --- a/server/crates/arbiter-server/src/evm/policies/ether_transfer/tests.rs +++ b/server/crates/arbiter-server/src/evm/policies/ether_transfer/tests.rs @@ -198,7 +198,7 @@ async fn evaluate_rejects_volume_over_limit() { grant_id, wallet_access_id: WALLET_ACCESS_ID, chain_id: CHAIN_ID as i32, - eth_value: utils::u256_to_bytes(U256::from(1_001u64)).to_vec(), + eth_value: utils::u256_to_bytes(U256::from(1_000u64)).to_vec(), signed_at: SqliteTimestamp(Utc::now()), }) .execute(&mut *conn) @@ -211,7 +211,7 @@ async fn evaluate_rejects_volume_over_limit() { shared: shared(), settings, }; - let context = ctx(ALLOWED, U256::from(100u64)); + let context = ctx(ALLOWED, U256::from(1u64)); let m = EtherTransfer::analyze(&context).unwrap(); let v = EtherTransfer::evaluate(&context, &m, &grant, &mut *conn) .await @@ -233,13 +233,13 @@ async fn evaluate_passes_at_exactly_volume_limit() { .await .unwrap(); - // Exactly at the limit — the check is `>`, so this should not violate + // Exactly at the limit including current transfer — check is `>`, so this should not violate insert_into(evm_transaction_log::table) .values(NewEvmTransactionLog { grant_id, wallet_access_id: WALLET_ACCESS_ID, chain_id: CHAIN_ID as i32, - eth_value: utils::u256_to_bytes(U256::from(1_000u64)).to_vec(), + eth_value: utils::u256_to_bytes(U256::from(900u64)).to_vec(), signed_at: SqliteTimestamp(Utc::now()), }) .execute(&mut *conn) diff --git a/server/crates/arbiter-server/src/evm/policies/token_transfers/mod.rs b/server/crates/arbiter-server/src/evm/policies/token_transfers/mod.rs index bfd8ba2..cef49d9 100644 --- a/server/crates/arbiter-server/src/evm/policies/token_transfers/mod.rs +++ b/server/crates/arbiter-server/src/evm/policies/token_transfers/mod.rs @@ -38,9 +38,9 @@ fn grant_join() -> _ { #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub struct Meaning { - token: &'static TokenInfo, - to: Address, - value: U256, + pub(crate) token: &'static TokenInfo, + pub(crate) to: Address, + pub(crate) value: U256, } impl std::fmt::Display for Meaning { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { @@ -101,6 +101,7 @@ async fn query_relevant_past_transfers( async fn check_volume_rate_limits( grant: &Grant, + current_transfer_value: U256, db: &mut impl AsyncConnection, ) -> QueryResult> { let mut violations = Vec::new(); @@ -113,12 +114,12 @@ async fn check_volume_rate_limits( for limit in &grant.settings.volume_limits { let window_start = chrono::Utc::now() - limit.window; - let cumulative_volume: U256 = past_transfers + let prospective_cumulative_volume: U256 = past_transfers .iter() .filter(|(_, timestamp)| timestamp >= &window_start) - .fold(U256::default(), |acc, (value, _)| acc + *value); + .fold(current_transfer_value, |acc, (value, _)| acc + *value); - if cumulative_volume > limit.max_volume { + if prospective_cumulative_volume > limit.max_volume { violations.push(EvalViolation::VolumetricLimitExceeded); break; } @@ -163,7 +164,7 @@ impl Policy for TokenTransfer { violations.push(EvalViolation::InvalidTarget { target: meaning.to }); } - let rate_violations = check_volume_rate_limits(grant, db).await?; + let rate_violations = check_volume_rate_limits(grant, meaning.value, db).await?; violations.extend(rate_violations); Ok(violations) diff --git a/server/crates/arbiter-server/src/evm/policies/token_transfers/tests.rs b/server/crates/arbiter-server/src/evm/policies/token_transfers/tests.rs index d8a5947..2f1b72f 100644 --- a/server/crates/arbiter-server/src/evm/policies/token_transfers/tests.rs +++ b/server/crates/arbiter-server/src/evm/policies/token_transfers/tests.rs @@ -220,7 +220,7 @@ async fn evaluate_rejects_wrong_restricted_recipient() { } #[tokio::test] -async fn evaluate_passes_volume_within_limit() { +async fn evaluate_passes_volume_at_exact_limit() { let db = db::create_test_pool().await; let mut conn = db.get().await.unwrap(); @@ -230,7 +230,7 @@ async fn evaluate_passes_volume_within_limit() { .await .unwrap(); - // Record a past transfer of 500 (within 1000 limit) + // 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 { @@ -239,7 +239,7 @@ async fn evaluate_passes_volume_within_limit() { chain_id: CHAIN_ID as i32, token_contract: DAI.to_vec(), recipient_address: RECIPIENT.to_vec(), - value: utils::u256_to_bytes(U256::from(500u64)).to_vec(), + value: utils::u256_to_bytes(U256::from(900u64)).to_vec(), }) .execute(&mut *conn) .await @@ -282,7 +282,7 @@ async fn evaluate_rejects_volume_over_limit() { chain_id: CHAIN_ID as i32, token_contract: DAI.to_vec(), recipient_address: RECIPIENT.to_vec(), - value: utils::u256_to_bytes(U256::from(1_001u64)).to_vec(), + value: utils::u256_to_bytes(U256::from(1_000u64)).to_vec(), }) .execute(&mut *conn) .await @@ -294,7 +294,7 @@ async fn evaluate_rejects_volume_over_limit() { shared: shared(), settings, }; - let calldata = transfer_calldata(RECIPIENT, U256::from(100u64)); + let calldata = transfer_calldata(RECIPIENT, U256::from(1u64)); let context = ctx(DAI, calldata); let m = TokenTransfer::analyze(&context).unwrap(); let v = TokenTransfer::evaluate(&context, &m, &grant, &mut *conn) diff --git a/server/crates/arbiter-server/src/grpc/client.rs b/server/crates/arbiter-server/src/grpc/client.rs index 7fff51c..52fa1ea 100644 --- a/server/crates/arbiter-server/src/grpc/client.rs +++ b/server/crates/arbiter-server/src/grpc/client.rs @@ -1,36 +1,24 @@ use arbiter_proto::{ - proto::{ - client::{ - ClientRequest, ClientResponse, - client_request::Payload as ClientRequestPayload, - client_response::Payload as ClientResponsePayload, - vault::{self as proto_vault, request::Payload as VaultRequestPayload, response::Payload as VaultResponsePayload}, - }, - shared::VaultState as ProtoVaultState, + proto::client::{ + ClientRequest, ClientResponse, client_request::Payload as ClientRequestPayload, + client_response::Payload as ClientResponsePayload, }, transport::{Receiver, Sender, grpc::GrpcBi}, }; -use kameo::{ - actor::{ActorRef, Spawn as _}, - error::SendError, -}; +use kameo::actor::{ActorRef, Spawn as _}; use tonic::Status; use tracing::{info, warn}; use crate::{ - actors::{ - client::{ - self, ClientConnection, - session::{ClientSession, Error, HandleQueryVaultState}, - }, - keyholder::KeyHolderState, - }, + actors::client::{ClientConnection, session::ClientSession}, grpc::request_tracker::RequestTracker, }; mod auth; +mod evm; mod inbound; mod outbound; +mod vault; async fn dispatch_loop( mut bi: GrpcBi, @@ -38,7 +26,9 @@ async fn dispatch_loop( mut request_tracker: RequestTracker, ) { loop { - let Some(message) = bi.recv().await else { return }; + let Some(message) = bi.recv().await else { + return; + }; let conn = match message { Ok(conn) => conn, @@ -57,16 +47,24 @@ async fn dispatch_loop( }; let Some(payload) = conn.payload else { - let _ = bi.send(Err(Status::invalid_argument("Missing client request payload"))).await; + let _ = bi + .send(Err(Status::invalid_argument( + "Missing client request payload", + ))) + .await; return; }; match dispatch_inner(&actor, payload).await { Ok(response) => { - if bi.send(Ok(ClientResponse { - request_id: Some(request_id), - payload: Some(response), - })).await.is_err() { + if bi + .send(Ok(ClientResponse { + request_id: Some(request_id), + payload: Some(response), + })) + .await + .is_err() + { return; } } @@ -83,52 +81,33 @@ async fn dispatch_inner( payload: ClientRequestPayload, ) -> Result { match payload { - ClientRequestPayload::Vault(req) => dispatch_vault_request(actor, req).await, - payload => { - warn!(?payload, "Unsupported post-auth client request"); + ClientRequestPayload::Vault(req) => vault::dispatch(actor, req).await, + ClientRequestPayload::Evm(req) => evm::dispatch(actor, req).await, + ClientRequestPayload::Auth(..) => { + warn!("Unsupported post-auth client auth request"); Err(Status::invalid_argument("Unsupported client request")) } } } -async fn dispatch_vault_request( - actor: &ActorRef, - req: proto_vault::Request, -) -> Result { - let Some(payload) = req.payload else { - return Err(Status::invalid_argument("Missing client vault request payload")); - }; - - match payload { - VaultRequestPayload::QueryState(_) => { - let state = match actor.ask(HandleQueryVaultState {}).await { - Ok(KeyHolderState::Unbootstrapped) => ProtoVaultState::Unbootstrapped, - Ok(KeyHolderState::Sealed) => ProtoVaultState::Sealed, - Ok(KeyHolderState::Unsealed) => ProtoVaultState::Unsealed, - Err(SendError::HandlerError(Error::Internal)) => ProtoVaultState::Error, - Err(err) => { - warn!(error = ?err, "Failed to query vault state"); - ProtoVaultState::Error - } - }; - Ok(ClientResponsePayload::Vault(proto_vault::Response { - payload: Some(VaultResponsePayload::State(state.into())), - })) - } - } -} - pub async fn start(mut conn: ClientConnection, mut bi: GrpcBi) { let mut request_tracker = RequestTracker::default(); - if let Err(e) = auth::start(&mut conn, &mut bi, &mut request_tracker).await { - let mut transport = auth::AuthTransportAdapter::new(&mut bi, &mut request_tracker); - let _ = transport.send(Err(e.clone())).await; - warn!(error = ?e, "Client authentication failed"); - return; + let client_id = match auth::start(&mut conn, &mut bi, &mut request_tracker).await { + Ok(id) => id, + Err(err) => { + let _ = bi + .send(Err(Status::unauthenticated(format!( + "Authentication failed: {}", + err + )))) + .await; + warn!(error = ?err, "Client authentication failed"); + return; + } }; - let actor = client::session::ClientSession::spawn(client::session::ClientSession::new(conn)); + let actor = ClientSession::spawn(ClientSession::new(conn, client_id)); let actor_for_cleanup = actor.clone(); info!("Client authenticated successfully"); diff --git a/server/crates/arbiter-server/src/grpc/client/auth.rs b/server/crates/arbiter-server/src/grpc/client/auth.rs index e5e141d..84fdffa 100644 --- a/server/crates/arbiter-server/src/grpc/client/auth.rs +++ b/server/crates/arbiter-server/src/grpc/client/auth.rs @@ -14,7 +14,7 @@ use arbiter_proto::{ }, shared::ClientInfo as ProtoClientInfo, }, - transport::{Bi, Error as TransportError, Receiver, Sender, grpc::GrpcBi} + transport::{Bi, Error as TransportError, Receiver, Sender, grpc::GrpcBi}, }; use async_trait::async_trait; use tonic::Status; @@ -49,7 +49,9 @@ impl<'a> AuthTransportAdapter<'a> { nonce, }) } - auth::Outbound::AuthSuccess => AuthResponsePayload::Result(ProtoAuthResult::Success.into()), + auth::Outbound::AuthSuccess => { + AuthResponsePayload::Result(ProtoAuthResult::Success.into()) + } } } @@ -197,8 +199,7 @@ pub async fn start( conn: &mut ClientConnection, bi: &mut GrpcBi, request_tracker: &mut RequestTracker, -) -> Result<(), auth::Error> { +) -> Result { let mut transport = AuthTransportAdapter::new(bi, request_tracker); - client::auth::authenticate(conn, &mut transport).await?; - Ok(()) + client::auth::authenticate(conn, &mut transport).await } diff --git a/server/crates/arbiter-server/src/grpc/client/evm.rs b/server/crates/arbiter-server/src/grpc/client/evm.rs new file mode 100644 index 0000000..b44234f --- /dev/null +++ b/server/crates/arbiter-server/src/grpc/client/evm.rs @@ -0,0 +1,85 @@ +use arbiter_proto::proto::{ + client::{ + client_response::Payload as ClientResponsePayload, + evm::{ + self as proto_evm, request::Payload as EvmRequestPayload, + response::Payload as EvmResponsePayload, + }, + }, + evm::{ + EvmError as ProtoEvmError, EvmSignTransactionResponse, + evm_sign_transaction_response::Result as EvmSignTransactionResult, + }, +}; +use kameo::actor::ActorRef; +use tonic::Status; +use tracing::warn; + +use crate::{ + actors::client::session::{ClientSession, HandleSignTransaction, SignTransactionRpcError}, + grpc::{ + Convert, TryConvert, + common::inbound::{RawEvmAddress, RawEvmTransaction}, + }, +}; + +fn wrap_response(payload: EvmResponsePayload) -> ClientResponsePayload { + ClientResponsePayload::Evm(proto_evm::Response { + payload: Some(payload), + }) +} + +pub(super) async fn dispatch( + actor: &ActorRef, + req: proto_evm::Request, +) -> Result { + let Some(payload) = req.payload else { + return Err(Status::invalid_argument("Missing client EVM request payload")); + }; + + match payload { + EvmRequestPayload::SignTransaction(request) => { + let address = RawEvmAddress(request.wallet_address).try_convert()?; + let transaction = RawEvmTransaction(request.rlp_transaction).try_convert()?; + + let response = match actor + .ask(HandleSignTransaction { + wallet_address: address, + transaction, + }) + .await + { + Ok(signature) => EvmSignTransactionResponse { + result: Some(EvmSignTransactionResult::Signature( + signature.as_bytes().to_vec(), + )), + }, + Err(kameo::error::SendError::HandlerError(SignTransactionRpcError::Vet( + vet_error, + ))) => EvmSignTransactionResponse { + result: Some(vet_error.convert()), + }, + Err(kameo::error::SendError::HandlerError( + SignTransactionRpcError::Internal, + )) => EvmSignTransactionResponse { + result: Some(EvmSignTransactionResult::Error( + ProtoEvmError::Internal.into(), + )), + }, + Err(err) => { + warn!(error = ?err, "Failed to sign EVM transaction"); + EvmSignTransactionResponse { + result: Some(EvmSignTransactionResult::Error( + ProtoEvmError::Internal.into(), + )), + } + } + }; + + Ok(wrap_response(EvmResponsePayload::SignTransaction(response))) + } + EvmRequestPayload::AnalyzeTransaction(_) => { + Err(Status::unimplemented("EVM transaction analysis is not yet implemented")) + } + } +} diff --git a/server/crates/arbiter-server/src/grpc/client/vault.rs b/server/crates/arbiter-server/src/grpc/client/vault.rs new file mode 100644 index 0000000..241580f --- /dev/null +++ b/server/crates/arbiter-server/src/grpc/client/vault.rs @@ -0,0 +1,47 @@ +use arbiter_proto::proto::{ + client::{ + client_response::Payload as ClientResponsePayload, + vault::{ + self as proto_vault, request::Payload as VaultRequestPayload, + response::Payload as VaultResponsePayload, + }, + }, + shared::VaultState as ProtoVaultState, +}; +use kameo::{actor::ActorRef, error::SendError}; +use tonic::Status; +use tracing::warn; + +use crate::{ + actors::{ + client::session::{ClientSession, Error, HandleQueryVaultState}, + keyholder::KeyHolderState, + }, +}; + +pub(super) async fn dispatch( + actor: &ActorRef, + req: proto_vault::Request, +) -> Result { + let Some(payload) = req.payload else { + return Err(Status::invalid_argument("Missing client vault request payload")); + }; + + match payload { + VaultRequestPayload::QueryState(_) => { + let state = match actor.ask(HandleQueryVaultState {}).await { + Ok(KeyHolderState::Unbootstrapped) => ProtoVaultState::Unbootstrapped, + Ok(KeyHolderState::Sealed) => ProtoVaultState::Sealed, + Ok(KeyHolderState::Unsealed) => ProtoVaultState::Unsealed, + Err(SendError::HandlerError(Error::Internal)) => ProtoVaultState::Error, + Err(err) => { + warn!(error = ?err, "Failed to query vault state"); + ProtoVaultState::Error + } + }; + Ok(ClientResponsePayload::Vault(proto_vault::Response { + payload: Some(VaultResponsePayload::State(state.into())), + })) + } + } +} diff --git a/server/crates/arbiter-server/src/grpc/common.rs b/server/crates/arbiter-server/src/grpc/common.rs new file mode 100644 index 0000000..5756441 --- /dev/null +++ b/server/crates/arbiter-server/src/grpc/common.rs @@ -0,0 +1,2 @@ +pub mod inbound; +pub mod outbound; diff --git a/server/crates/arbiter-server/src/grpc/common/inbound.rs b/server/crates/arbiter-server/src/grpc/common/inbound.rs new file mode 100644 index 0000000..bf083f0 --- /dev/null +++ b/server/crates/arbiter-server/src/grpc/common/inbound.rs @@ -0,0 +1,36 @@ +use alloy::{consensus::TxEip1559, primitives::Address, rlp::Decodable as _}; + +use crate::grpc::TryConvert; + +pub struct RawEvmAddress(pub Vec); +impl TryConvert for RawEvmAddress { + type Output = Address; + + type Error = tonic::Status; + + fn try_convert(self) -> Result { + let wallet_address = match <[u8; 20]>::try_from(self.0.as_slice()) { + Ok(address) => Address::from(address), + Err(_) => { + return Err(tonic::Status::invalid_argument( + "Invalid EVM wallet address", + )); + } + }; + Ok(wallet_address) + } +} + +pub struct RawEvmTransaction(pub Vec); +impl TryConvert for RawEvmTransaction { + type Output = TxEip1559; + + type Error = tonic::Status; + + fn try_convert(mut self) -> Result { + let tx = TxEip1559::decode(&mut self.0.as_slice()).map_err(|_| { + tonic::Status::invalid_argument("Invalid EVM transaction format") + })?; + Ok(tx) + } +} \ No newline at end of file diff --git a/server/crates/arbiter-server/src/grpc/common/outbound.rs b/server/crates/arbiter-server/src/grpc/common/outbound.rs new file mode 100644 index 0000000..d2c6e7d --- /dev/null +++ b/server/crates/arbiter-server/src/grpc/common/outbound.rs @@ -0,0 +1,116 @@ +use alloy::primitives::U256; +use arbiter_proto::proto::{ + evm::{EvmError as ProtoEvmError, evm_sign_transaction_response::Result as EvmSignTransactionResult}, + shared::evm::{ + EvalViolation as ProtoEvalViolation, GasLimitExceededViolation, + NoMatchingGrantError, PolicyViolationsError, SpecificMeaning as ProtoSpecificMeaning, + TokenInfo as ProtoTokenInfo, TransactionEvalError as ProtoTransactionEvalError, + eval_violation::Kind as ProtoEvalViolationKind, + specific_meaning::Meaning as ProtoSpecificMeaningKind, + transaction_eval_error::Kind as ProtoTransactionEvalErrorKind, + }, +}; + +use crate::{ + evm::{ + PolicyError, VetError, + policies::{EvalViolation, SpecificMeaning}, + }, + grpc::Convert, +}; + +fn u256_to_proto_bytes(value: U256) -> Vec { + value.to_be_bytes::<32>().to_vec() +} + +impl Convert for SpecificMeaning { + type Output = ProtoSpecificMeaning; + + fn convert(self) -> Self::Output { + let kind = match self { + SpecificMeaning::EtherTransfer(meaning) => ProtoSpecificMeaningKind::EtherTransfer( + arbiter_proto::proto::shared::evm::EtherTransferMeaning { + to: meaning.to.to_vec(), + value: u256_to_proto_bytes(meaning.value), + }, + ), + SpecificMeaning::TokenTransfer(meaning) => ProtoSpecificMeaningKind::TokenTransfer( + arbiter_proto::proto::shared::evm::TokenTransferMeaning { + token: Some(ProtoTokenInfo { + symbol: meaning.token.symbol.to_string(), + address: meaning.token.contract.to_vec(), + chain_id: meaning.token.chain, + }), + to: meaning.to.to_vec(), + value: u256_to_proto_bytes(meaning.value), + }, + ), + }; + + ProtoSpecificMeaning { + meaning: Some(kind), + } + } +} + +impl Convert for EvalViolation { + type Output = ProtoEvalViolation; + + fn convert(self) -> Self::Output { + let kind = match self { + EvalViolation::InvalidTarget { target } => { + ProtoEvalViolationKind::InvalidTarget(target.to_vec()) + } + EvalViolation::GasLimitExceeded { + max_gas_fee_per_gas, + max_priority_fee_per_gas, + } => ProtoEvalViolationKind::GasLimitExceeded(GasLimitExceededViolation { + max_gas_fee_per_gas: max_gas_fee_per_gas.map(u256_to_proto_bytes), + max_priority_fee_per_gas: max_priority_fee_per_gas.map(u256_to_proto_bytes), + }), + EvalViolation::RateLimitExceeded => ProtoEvalViolationKind::RateLimitExceeded(()), + EvalViolation::VolumetricLimitExceeded => { + ProtoEvalViolationKind::VolumetricLimitExceeded(()) + } + EvalViolation::InvalidTime => ProtoEvalViolationKind::InvalidTime(()), + EvalViolation::InvalidTransactionType => { + ProtoEvalViolationKind::InvalidTransactionType(()) + } + }; + + ProtoEvalViolation { kind: Some(kind) } + } +} + +impl Convert for VetError { + type Output = EvmSignTransactionResult; + + fn convert(self) -> Self::Output { + let kind = match self { + VetError::ContractCreationNotSupported => { + ProtoTransactionEvalErrorKind::ContractCreationNotSupported(()) + } + VetError::UnsupportedTransactionType => { + ProtoTransactionEvalErrorKind::UnsupportedTransactionType(()) + } + VetError::Evaluated(meaning, policy_error) => match policy_error { + PolicyError::NoMatchingGrant => { + ProtoTransactionEvalErrorKind::NoMatchingGrant(NoMatchingGrantError { + meaning: Some(meaning.convert()), + }) + } + PolicyError::Violations(violations) => { + ProtoTransactionEvalErrorKind::PolicyViolations(PolicyViolationsError { + meaning: Some(meaning.convert()), + violations: violations.into_iter().map(Convert::convert).collect(), + }) + } + PolicyError::Database(_) => { + return EvmSignTransactionResult::Error(ProtoEvmError::Internal.into()); + } + }, + }; + + EvmSignTransactionResult::EvalError(ProtoTransactionEvalError { kind: Some(kind) }.into()) + } +} diff --git a/server/crates/arbiter-server/src/grpc/mod.rs b/server/crates/arbiter-server/src/grpc/mod.rs index 149f0cb..7181594 100644 --- a/server/crates/arbiter-server/src/grpc/mod.rs +++ b/server/crates/arbiter-server/src/grpc/mod.rs @@ -14,10 +14,13 @@ use crate::{ grpc::user_agent::start, }; -pub mod client; mod request_tracker; + +pub mod client; pub mod user_agent; +mod common; + pub trait Convert { type Output; diff --git a/server/crates/arbiter-server/src/grpc/user_agent/evm.rs b/server/crates/arbiter-server/src/grpc/user_agent/evm.rs index e64a9ec..c9b02eb 100644 --- a/server/crates/arbiter-server/src/grpc/user_agent/evm.rs +++ b/server/crates/arbiter-server/src/grpc/user_agent/evm.rs @@ -2,15 +2,20 @@ use arbiter_proto::proto::{ evm::{ EvmError as ProtoEvmError, EvmGrantCreateRequest, EvmGrantCreateResponse, EvmGrantDeleteRequest, EvmGrantDeleteResponse, EvmGrantList, EvmGrantListResponse, - GrantEntry, WalletCreateResponse, WalletEntry, WalletList, WalletListResponse, + EvmSignTransactionResponse, GrantEntry, WalletCreateResponse, WalletEntry, WalletList, + WalletListResponse, evm_grant_create_response::Result as EvmGrantCreateResult, evm_grant_delete_response::Result as EvmGrantDeleteResult, evm_grant_list_response::Result as EvmGrantListResult, + evm_sign_transaction_response::Result as EvmSignTransactionResult, wallet_create_response::Result as WalletCreateResult, wallet_list_response::Result as WalletListResult, }, user_agent::{ - evm::{self as proto_evm, request::Payload as EvmRequestPayload, response::Payload as EvmResponsePayload}, + evm::{ + self as proto_evm, SignTransactionRequest as ProtoSignTransactionRequest, + request::Payload as EvmRequestPayload, response::Payload as EvmResponsePayload, + }, user_agent_response::Payload as UserAgentResponsePayload, }, }; @@ -23,10 +28,14 @@ use crate::{ UserAgentSession, session::connection::{ HandleEvmWalletCreate, HandleEvmWalletList, HandleGrantCreate, HandleGrantDelete, - HandleGrantList, + HandleGrantList, HandleSignTransaction, + SignTransactionError as SessionSignTransactionError, }, }, - grpc::{Convert, TryConvert}, + grpc::{ + Convert, TryConvert, + common::inbound::{RawEvmAddress, RawEvmTransaction}, + }, }; fn wrap_evm_response(payload: EvmResponsePayload) -> UserAgentResponsePayload { @@ -49,6 +58,7 @@ pub(super) async fn dispatch( EvmRequestPayload::GrantCreate(req) => handle_grant_create(actor, req).await, EvmRequestPayload::GrantDelete(req) => handle_grant_delete(actor, req).await, EvmRequestPayload::GrantList(_) => handle_grant_list(actor).await, + EvmRequestPayload::SignTransaction(req) => handle_sign_transaction(actor, req).await, } } @@ -168,3 +178,53 @@ async fn handle_grant_delete( }, )))) } + +async fn handle_sign_transaction( + actor: &ActorRef, + req: ProtoSignTransactionRequest, +) -> Result, Status> { + let request = req + .request + .ok_or_else(|| Status::invalid_argument("Missing sign transaction request"))?; + let wallet_address = RawEvmAddress(request.wallet_address).try_convert()?; + let transaction = RawEvmTransaction(request.rlp_transaction).try_convert()?; + + let response = match actor + .ask(HandleSignTransaction { + client_id: req.client_id, + wallet_address, + transaction, + }) + .await + { + Ok(signature) => EvmSignTransactionResponse { + result: Some(EvmSignTransactionResult::Signature( + signature.as_bytes().to_vec(), + )), + }, + Err(kameo::error::SendError::HandlerError( + SessionSignTransactionError::Vet(vet_error), + )) => EvmSignTransactionResponse { + result: Some(vet_error.convert()), + }, + Err(kameo::error::SendError::HandlerError( + SessionSignTransactionError::Internal, + )) => EvmSignTransactionResponse { + result: Some(EvmSignTransactionResult::Error( + ProtoEvmError::Internal.into(), + )), + }, + Err(err) => { + warn!(error = ?err, "Failed to sign EVM transaction"); + EvmSignTransactionResponse { + result: Some(EvmSignTransactionResult::Error( + ProtoEvmError::Internal.into(), + )), + } + } + }; + + Ok(Some(wrap_evm_response(EvmResponsePayload::SignTransaction( + response, + )))) +} diff --git a/server/crates/arbiter-server/tests/user_agent/auth.rs b/server/crates/arbiter-server/tests/user_agent/auth.rs index 285ddcf..3fd8c92 100644 --- a/server/crates/arbiter-server/tests/user_agent/auth.rs +++ b/server/crates/arbiter-server/tests/user_agent/auth.rs @@ -165,3 +165,69 @@ pub async fn test_challenge_auth() { task.await.unwrap().unwrap(); } + +#[tokio::test] +#[test_log::test] +pub async fn test_challenge_auth_rejects_invalid_signature() { + let db = db::create_test_pool().await; + let actors = GlobalActors::spawn(db.clone()).await.unwrap(); + + let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng()); + let pubkey_bytes = new_key.verifying_key().to_bytes().to_vec(); + + // Pre-register key with key_type + { + let mut conn = db.get().await.unwrap(); + insert_into(schema::useragent_client::table) + .values(( + schema::useragent_client::public_key.eq(pubkey_bytes.clone()), + schema::useragent_client::key_type.eq(1i32), + )) + .execute(&mut conn) + .await + .unwrap(); + } + + let (server_transport, mut test_transport) = ChannelTransport::new(); + let db_for_task = db.clone(); + let task = tokio::spawn(async move { + let mut props = UserAgentConnection::new(db_for_task, actors); + auth::authenticate(&mut props, server_transport).await + }); + + test_transport + .send(auth::Inbound::AuthChallengeRequest { + pubkey: AuthPublicKey::Ed25519(new_key.verifying_key()), + bootstrap_token: None, + }) + .await + .unwrap(); + + let response = test_transport + .recv() + .await + .expect("should receive challenge"); + let challenge = match response { + Ok(resp) => match resp { + auth::Outbound::AuthChallenge { nonce } => nonce, + other => panic!("Expected AuthChallenge, got {other:?}"), + }, + Err(err) => panic!("Expected Ok response, got Err({err:?})"), + }; + + // Sign a different challenge value so signature format is valid but verification must fail. + let wrong_challenge = arbiter_proto::format_challenge(challenge + 1, &pubkey_bytes); + let signature = new_key.sign(&wrong_challenge); + + test_transport + .send(auth::Inbound::AuthChallengeSolution { + signature: signature.to_bytes().to_vec(), + }) + .await + .unwrap(); + + assert!(matches!( + task.await.unwrap(), + Err(auth::Error::InvalidChallengeSolution) + )); +}