From 02980468db703a7ec87adbfef71924270d3bbf0c Mon Sep 17 00:00:00 2001 From: CleverWild Date: Sun, 15 Mar 2026 19:21:55 +0100 Subject: [PATCH 01/20] feat(poc): add terrors PoC crate scaffold and error types Co-Authored-By: Claude Sonnet 4.6 --- protobufs/client.proto | 1 + protobufs/user_agent.proto | 70 ++++- server/Cargo.lock | 12 + server/crates/arbiter-client/Cargo.toml | 14 + server/crates/arbiter-client/src/lib.rs | 276 +++++++++++++++++- .../down.sql | 1 + .../up.sql | 2 + .../arbiter-server/src/actors/client/auth.rs | 111 ++----- .../src/actors/client/session.rs | 75 ++++- .../arbiter-server/src/actors/router/mod.rs | 102 +------ .../src/actors/user_agent/session.rs | 276 ++++++++++++++---- server/crates/arbiter-server/src/lib.rs | 2 +- .../arbiter-server/tests/client/auth.rs | 119 +++++++- .../crates/arbiter-server/tests/user_agent.rs | 2 + .../tests/user_agent/sdk_client.rs | 270 +++++++++++++++++ server/crates/arbiter-terrors-poc/Cargo.toml | 7 + .../crates/arbiter-terrors-poc/src/errors.rs | 84 ++++++ server/crates/arbiter-terrors-poc/src/main.rs | 3 + 18 files changed, 1144 insertions(+), 283 deletions(-) create mode 100644 server/crates/arbiter-server/migrations/2026-03-15-103112-0000_add_program_client_pubkey_unique/down.sql create mode 100644 server/crates/arbiter-server/migrations/2026-03-15-103112-0000_add_program_client_pubkey_unique/up.sql create mode 100644 server/crates/arbiter-server/tests/user_agent/sdk_client.rs create mode 100644 server/crates/arbiter-terrors-poc/Cargo.toml create mode 100644 server/crates/arbiter-terrors-poc/src/errors.rs create mode 100644 server/crates/arbiter-terrors-poc/src/main.rs diff --git a/protobufs/client.proto b/protobufs/client.proto index 62761c3..1f70371 100644 --- a/protobufs/client.proto +++ b/protobufs/client.proto @@ -23,6 +23,7 @@ message ClientRequest { oneof payload { AuthChallengeRequest auth_challenge_request = 1; AuthChallengeSolution auth_challenge_solution = 2; + arbiter.evm.EvmSignTransactionRequest evm_sign_transaction = 3; } } diff --git a/protobufs/user_agent.proto b/protobufs/user_agent.proto index fcf508d..2a7e3c0 100644 --- a/protobufs/user_agent.proto +++ b/protobufs/user_agent.proto @@ -12,6 +12,55 @@ enum KeyType { KEY_TYPE_RSA = 3; } +// --- SDK client management --- + +enum SdkClientError { + SDK_CLIENT_ERROR_UNSPECIFIED = 0; + SDK_CLIENT_ERROR_ALREADY_EXISTS = 1; + SDK_CLIENT_ERROR_NOT_FOUND = 2; + SDK_CLIENT_ERROR_HAS_RELATED_DATA = 3; // hard-delete blocked by FK (client has grants or transaction logs) + SDK_CLIENT_ERROR_INTERNAL = 4; +} + +message SdkClientApproveRequest { + bytes pubkey = 1; // 32-byte ed25519 public key +} + +message SdkClientRevokeRequest { + int32 client_id = 1; +} + +message SdkClientEntry { + int32 id = 1; + bytes pubkey = 2; + int32 created_at = 3; +} + +message SdkClientList { + repeated SdkClientEntry clients = 1; +} + +message SdkClientApproveResponse { + oneof result { + SdkClientEntry client = 1; + SdkClientError error = 2; + } +} + +message SdkClientRevokeResponse { + oneof result { + google.protobuf.Empty ok = 1; + SdkClientError error = 2; + } +} + +message SdkClientListResponse { + oneof result { + SdkClientList clients = 1; + SdkClientError error = 2; + } +} + message AuthChallengeRequest { bytes pubkey = 1; optional string bootstrap_token = 2; @@ -57,16 +106,6 @@ enum VaultState { VAULT_STATE_ERROR = 4; } -message ClientConnectionRequest { - bytes pubkey = 1; -} - -message ClientConnectionResponse { - bool approved = 1; -} - -message ClientConnectionCancel {} - message UserAgentRequest { oneof payload { AuthChallengeRequest auth_challenge_request = 1; @@ -79,7 +118,10 @@ message UserAgentRequest { arbiter.evm.EvmGrantCreateRequest evm_grant_create = 8; arbiter.evm.EvmGrantDeleteRequest evm_grant_delete = 9; arbiter.evm.EvmGrantListRequest evm_grant_list = 10; - ClientConnectionResponse client_connection_response = 11; + // field 11 reserved: was client_connection_response (online approval removed) + SdkClientApproveRequest sdk_client_approve = 12; + SdkClientRevokeRequest sdk_client_revoke = 13; + google.protobuf.Empty sdk_client_list = 14; } } message UserAgentResponse { @@ -94,7 +136,9 @@ message UserAgentResponse { arbiter.evm.EvmGrantCreateResponse evm_grant_create = 8; arbiter.evm.EvmGrantDeleteResponse evm_grant_delete = 9; arbiter.evm.EvmGrantListResponse evm_grant_list = 10; - ClientConnectionRequest client_connection_request = 11; - ClientConnectionCancel client_connection_cancel = 12; + // fields 11, 12 reserved: were client_connection_request, client_connection_cancel (online approval removed) + SdkClientApproveResponse sdk_client_approve = 13; + SdkClientRevokeResponse sdk_client_revoke = 14; + SdkClientListResponse sdk_client_list = 15; } } diff --git a/server/Cargo.lock b/server/Cargo.lock index 1586320..2d0b912 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -678,6 +678,18 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "arbiter-client" version = "0.1.0" +dependencies = [ + "alloy", + "arbiter-proto", + "async-trait", + "ed25519-dalek", + "http", + "rustls-webpki", + "thiserror", + "tokio", + "tokio-stream", + "tonic", +] [[package]] name = "arbiter-proto" diff --git a/server/crates/arbiter-client/Cargo.toml b/server/crates/arbiter-client/Cargo.toml index e71c9e7..597a26e 100644 --- a/server/crates/arbiter-client/Cargo.toml +++ b/server/crates/arbiter-client/Cargo.toml @@ -5,4 +5,18 @@ edition = "2024" repository = "https://git.markettakers.org/MarketTakers/arbiter" license = "Apache-2.0" +[lints] +workspace = true + [dependencies] +arbiter-proto.path = "../arbiter-proto" +alloy.workspace = true +tonic.workspace = true +tonic.features = ["tls-aws-lc"] +tokio.workspace = true +tokio-stream.workspace = true +ed25519-dalek.workspace = true +thiserror.workspace = true +http = "1.4.0" +rustls-webpki = { version = "0.103.9", features = ["aws-lc-rs"] } +async-trait.workspace = true diff --git a/server/crates/arbiter-client/src/lib.rs b/server/crates/arbiter-client/src/lib.rs index b93cf3f..322a9bb 100644 --- a/server/crates/arbiter-client/src/lib.rs +++ b/server/crates/arbiter-client/src/lib.rs @@ -1,14 +1,272 @@ -pub fn add(left: u64, right: u64) -> u64 { - left + right +use alloy::{ + consensus::SignableTransaction, + network::TxSigner, + primitives::{Address, B256, ChainId, Signature}, + signers::{Error, Result, Signer}, +}; +use arbiter_proto::{ + format_challenge, + proto::{ + arbiter_service_client::ArbiterServiceClient, + client::{ + AuthChallengeRequest, AuthChallengeSolution, ClientRequest, ClientResponse, + client_connect_error, client_request::Payload as ClientRequestPayload, + client_response::Payload as ClientResponsePayload, + }, + evm::{ + EvmSignTransactionRequest, evm_sign_transaction_response::Result as SignResponseResult, + }, + }, + url::ArbiterUrl, +}; +use async_trait::async_trait; +use ed25519_dalek::Signer as _; +use tokio::sync::{Mutex, mpsc}; +use tokio_stream::wrappers::ReceiverStream; +use tonic::transport::ClientTlsConfig; + +#[derive(Debug, thiserror::Error)] +pub enum ConnectError { + #[error("Could not establish connection")] + Connection(#[from] tonic::transport::Error), + + #[error("Invalid server URI")] + InvalidUri(#[from] http::uri::InvalidUri), + + #[error("Invalid CA certificate")] + InvalidCaCert(#[from] webpki::Error), + + #[error("gRPC error")] + Grpc(#[from] tonic::Status), + + #[error("Auth challenge was not returned by server")] + MissingAuthChallenge, + + #[error("Client approval denied by User Agent")] + ApprovalDenied, + + #[error("No User Agents online to approve client")] + NoUserAgentsOnline, + + #[error("Unexpected auth response payload")] + UnexpectedAuthResponse, } -#[cfg(test)] -mod tests { - use super::*; +#[derive(Debug, thiserror::Error)] +enum ClientSignError { + #[error("Transport channel closed")] + ChannelClosed, - #[test] - fn it_works() { - let result = add(2, 2); - assert_eq!(result, 4); + #[error("Connection closed by server")] + ConnectionClosed, + + #[error("Invalid response payload")] + InvalidResponse, + + #[error("Remote signing was rejected")] + Rejected, +} + +struct ClientTransport { + sender: mpsc::Sender, + receiver: tonic::Streaming, +} + +impl ClientTransport { + async fn send(&mut self, request: ClientRequest) -> std::result::Result<(), ClientSignError> { + self.sender + .send(request) + .await + .map_err(|_| ClientSignError::ChannelClosed) + } + + async fn recv(&mut self) -> std::result::Result { + match self.receiver.message().await { + Ok(Some(resp)) => Ok(resp), + Ok(None) => Err(ClientSignError::ConnectionClosed), + Err(_) => Err(ClientSignError::ConnectionClosed), + } + } +} + +pub struct ArbiterSigner { + transport: Mutex, + address: Address, + chain_id: Option, +} + +impl ArbiterSigner { + pub async fn connect_grpc( + url: ArbiterUrl, + key: ed25519_dalek::SigningKey, + address: Address, + ) -> std::result::Result { + let anchor = webpki::anchor_from_trusted_cert(&url.ca_cert)?.to_owned(); + let tls = ClientTlsConfig::new().trust_anchor(anchor); + + // NOTE: We intentionally keep the same URL construction strategy as the user-agent crate + // to avoid behavior drift between the two clients. + let channel = tonic::transport::Channel::from_shared(format!("{}:{}", url.host, url.port))? + .tls_config(tls)? + .connect() + .await?; + + let mut client = ArbiterServiceClient::new(channel); + let (tx, rx) = mpsc::channel(16); + let response_stream = client.client(ReceiverStream::new(rx)).await?.into_inner(); + + let mut transport = ClientTransport { + sender: tx, + receiver: response_stream, + }; + + authenticate(&mut transport, key).await?; + + Ok(Self { + transport: Mutex::new(transport), + address, + chain_id: None, + }) + } + + async fn sign_transaction_via_arbiter( + &self, + tx: &mut dyn SignableTransaction, + ) -> Result { + if let Some(chain_id) = self.chain_id + && !tx.set_chain_id_checked(chain_id) + { + return Err(Error::TransactionChainIdMismatch { + signer: chain_id, + tx: tx.chain_id().unwrap(), + }); + } + + let mut rlp_transaction = Vec::new(); + tx.encode_for_signing(&mut rlp_transaction); + + let request = ClientRequest { + payload: Some(ClientRequestPayload::EvmSignTransaction( + EvmSignTransactionRequest { + wallet_address: self.address.as_slice().to_vec(), + rlp_transaction, + }, + )), + }; + + let mut transport = self.transport.lock().await; + transport.send(request).await.map_err(Error::other)?; + let response = transport.recv().await.map_err(Error::other)?; + + let payload = response + .payload + .ok_or_else(|| Error::other(ClientSignError::InvalidResponse))?; + + let ClientResponsePayload::EvmSignTransaction(sign_response) = payload else { + return Err(Error::other(ClientSignError::InvalidResponse)); + }; + + let Some(result) = sign_response.result else { + return Err(Error::other(ClientSignError::InvalidResponse)); + }; + + match result { + SignResponseResult::Signature(bytes) => { + Signature::try_from(bytes.as_slice()).map_err(Error::other) + } + SignResponseResult::EvalError(_) | SignResponseResult::Error(_) => { + Err(Error::other(ClientSignError::Rejected)) + } + } + } +} + +async fn authenticate( + transport: &mut ClientTransport, + key: ed25519_dalek::SigningKey, +) -> std::result::Result<(), ConnectError> { + transport + .send(ClientRequest { + payload: Some(ClientRequestPayload::AuthChallengeRequest( + AuthChallengeRequest { + pubkey: key.verifying_key().to_bytes().to_vec(), + }, + )), + }) + .await + .map_err(|_| ConnectError::UnexpectedAuthResponse)?; + + let response = transport + .recv() + .await + .map_err(|_| ConnectError::MissingAuthChallenge)?; + + let payload = response.payload.ok_or(ConnectError::MissingAuthChallenge)?; + match payload { + ClientResponsePayload::AuthChallenge(challenge) => { + let challenge_payload = format_challenge(challenge.nonce, &challenge.pubkey); + let signature = key.sign(&challenge_payload).to_bytes().to_vec(); + + transport + .send(ClientRequest { + payload: Some(ClientRequestPayload::AuthChallengeSolution( + AuthChallengeSolution { signature }, + )), + }) + .await + .map_err(|_| ConnectError::UnexpectedAuthResponse)?; + + // Current server flow does not emit `AuthOk` for SDK clients, so we proceed after + // sending the solution. If authentication fails, the first business request will return + // a `ClientConnectError` or the stream will close. + Ok(()) + } + ClientResponsePayload::ClientConnectError(err) => { + match client_connect_error::Code::try_from(err.code) + .unwrap_or(client_connect_error::Code::Unknown) + { + client_connect_error::Code::ApprovalDenied => Err(ConnectError::ApprovalDenied), + client_connect_error::Code::NoUserAgentsOnline => { + Err(ConnectError::NoUserAgentsOnline) + } + client_connect_error::Code::Unknown => Err(ConnectError::UnexpectedAuthResponse), + } + } + _ => Err(ConnectError::UnexpectedAuthResponse), + } +} + +#[async_trait] +impl Signer for ArbiterSigner { + async fn sign_hash(&self, _hash: &B256) -> Result { + Err(Error::other( + "hash-only signing is not supported for ArbiterSigner; use transaction signing", + )) + } + + fn address(&self) -> Address { + self.address + } + + fn chain_id(&self) -> Option { + self.chain_id + } + + fn set_chain_id(&mut self, chain_id: Option) { + self.chain_id = chain_id; + } +} + +#[async_trait] +impl TxSigner for ArbiterSigner { + fn address(&self) -> Address { + self.address + } + + async fn sign_transaction( + &self, + tx: &mut dyn SignableTransaction, + ) -> Result { + self.sign_transaction_via_arbiter(tx).await } } diff --git a/server/crates/arbiter-server/migrations/2026-03-15-103112-0000_add_program_client_pubkey_unique/down.sql b/server/crates/arbiter-server/migrations/2026-03-15-103112-0000_add_program_client_pubkey_unique/down.sql new file mode 100644 index 0000000..aeda4ed --- /dev/null +++ b/server/crates/arbiter-server/migrations/2026-03-15-103112-0000_add_program_client_pubkey_unique/down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS program_client_public_key_unique; diff --git a/server/crates/arbiter-server/migrations/2026-03-15-103112-0000_add_program_client_pubkey_unique/up.sql b/server/crates/arbiter-server/migrations/2026-03-15-103112-0000_add_program_client_pubkey_unique/up.sql new file mode 100644 index 0000000..5d1d4a2 --- /dev/null +++ b/server/crates/arbiter-server/migrations/2026-03-15-103112-0000_add_program_client_pubkey_unique/up.sql @@ -0,0 +1,2 @@ +CREATE UNIQUE INDEX program_client_public_key_unique + ON program_client (public_key); diff --git a/server/crates/arbiter-server/src/actors/client/auth.rs b/server/crates/arbiter-server/src/actors/client/auth.rs index cb11d9a..649b987 100644 --- a/server/crates/arbiter-server/src/actors/client/auth.rs +++ b/server/crates/arbiter-server/src/actors/client/auth.rs @@ -8,19 +8,13 @@ use arbiter_proto::{ }, transport::expect_message, }; -use diesel::{ - ExpressionMethods as _, OptionalExtension as _, QueryDsl as _, dsl::insert_into, update, -}; +use diesel::{ExpressionMethods as _, OptionalExtension as _, QueryDsl as _, update}; use diesel_async::RunQueryDsl as _; use ed25519_dalek::VerifyingKey; -use kameo::error::SendError; use tracing::error; use crate::{ - actors::{ - client::ClientConnection, - router::{self, RequestClientApproval}, - }, + actors::client::ClientConnection, db::{self, schema::program_client}, }; @@ -40,27 +34,20 @@ pub enum Error { DatabaseOperationFailed, #[error("Invalid challenge solution")] InvalidChallengeSolution, - #[error("Client approval request failed")] - ApproveError(#[from] ApproveError), + #[error("Client not registered")] + NotRegistered, #[error("Internal error")] InternalError, #[error("Transport error")] Transport, } -#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)] -pub enum ApproveError { - #[error("Internal error")] - Internal, - #[error("Client connection denied by user agents")] - Denied, - #[error("Upstream error: {0}")] - Upstream(router::ApprovalError), -} - /// Atomically reads and increments the nonce for a known client. /// Returns `None` if the pubkey is not registered. -async fn get_nonce(db: &db::DatabasePool, pubkey: &VerifyingKey) -> Result, Error> { +async fn get_nonce( + db: &db::DatabasePool, + pubkey: &VerifyingKey, +) -> Result, Error> { let pubkey_bytes = pubkey.as_bytes().to_vec(); let mut conn = db.get().await.map_err(|e| { @@ -71,10 +58,10 @@ async fn get_nonce(db: &db::DatabasePool, pubkey: &VerifyingKey) -> Result(conn) + .select((program_client::id, program_client::nonce)) + .first::<(i32, i32)>(conn) .await .optional()? else { @@ -87,7 +74,7 @@ async fn get_nonce(db: &db::DatabasePool, pubkey: &VerifyingKey) -> Result Result Result<(), Error> { - let result = actors - .router - .ask(RequestClientApproval { - client_pubkey: pubkey, - }) - .await; - - match result { - Ok(true) => Ok(()), - Ok(false) => Err(Error::ApproveError(ApproveError::Denied)), - Err(SendError::HandlerError(e)) => { - error!(error = ?e, "Approval upstream error"); - Err(Error::ApproveError(ApproveError::Upstream(e))) - } - Err(e) => { - error!(error = ?e, "Approval request to router failed"); - Err(Error::ApproveError(ApproveError::Internal)) - } - } -} - -async fn insert_client(db: &db::DatabasePool, pubkey: &VerifyingKey) -> Result<(), Error> { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() as i32; - - let mut conn = db.get().await.map_err(|e| { - error!(error = ?e, "Database pool error"); - Error::DatabasePoolUnavailable - })?; - - insert_into(program_client::table) - .values(( - program_client::public_key.eq(pubkey.as_bytes().to_vec()), - program_client::nonce.eq(1), // pre-incremented; challenge uses 0 - program_client::created_at.eq(now), - program_client::updated_at.eq(now), - )) - .execute(&mut conn) - .await - .map_err(|e| { - error!(error = ?e, "Failed to insert new client"); - Error::DatabaseOperationFailed - })?; - - Ok(()) -} - async fn challenge_client( props: &mut ClientConnection, pubkey: VerifyingKey, @@ -200,15 +134,12 @@ async fn challenge_client( fn connect_error_code(err: &Error) -> ConnectErrorCode { match err { - Error::ApproveError(ApproveError::Denied) => ConnectErrorCode::ApprovalDenied, - Error::ApproveError(ApproveError::Upstream( - router::ApprovalError::NoUserAgentsConnected, - )) => ConnectErrorCode::NoUserAgentsOnline, + Error::NotRegistered => ConnectErrorCode::ApprovalDenied, _ => ConnectErrorCode::Unknown, } } -async fn authenticate(props: &mut ClientConnection) -> Result { +async fn authenticate(props: &mut ClientConnection) -> Result<(VerifyingKey, i32), Error> { let Some(ClientRequest { payload: Some(ClientRequestPayload::AuthChallengeRequest(challenge)), }) = props.transport.recv().await @@ -223,23 +154,19 @@ async fn authenticate(props: &mut ClientConnection) -> Result nonce, - None => { - approve_new_client(&props.actors, pubkey).await?; - insert_client(&props.db, &pubkey).await?; - 0 - } + let (client_id, nonce) = match get_nonce(&props.db, &pubkey).await? { + Some((client_id, nonce)) => (client_id, nonce), + None => return Err(Error::NotRegistered), }; challenge_client(props, pubkey, nonce).await?; - Ok(pubkey) + Ok((pubkey, client_id)) } pub async fn authenticate_and_create(mut props: ClientConnection) -> Result { match authenticate(&mut props).await { - Ok(_pubkey) => Ok(ClientSession::new(props)), + Ok((_pubkey, client_id)) => Ok(ClientSession::new(props, client_id)), Err(err) => { let code = connect_error_code(&err); let _ = props diff --git a/server/crates/arbiter-server/src/actors/client/session.rs b/server/crates/arbiter-server/src/actors/client/session.rs index a2ae4a4..63d19b6 100644 --- a/server/crates/arbiter-server/src/actors/client/session.rs +++ b/server/crates/arbiter-server/src/actors/client/session.rs @@ -1,19 +1,35 @@ -use arbiter_proto::proto::client::{ClientRequest, ClientResponse}; +use alloy::{consensus::TxEip1559, primitives::Address, rlp::Decodable}; +use arbiter_proto::proto::{ + client::{ + ClientRequest, ClientResponse, client_request::Payload as ClientRequestPayload, + client_response::Payload as ClientResponsePayload, + }, + evm::{ + EvmError, EvmSignTransactionResponse, evm_sign_transaction_response::Result as SignResult, + }, +}; use kameo::Actor; use tokio::select; use tracing::{error, info}; -use crate::{actors::{ - GlobalActors, client::{ClientError, ClientConnection}, router::RegisterClient -}, db}; +use crate::{ + actors::{ + GlobalActors, + client::{ClientConnection, ClientError}, + evm::ClientSignTransaction, + router::RegisterClient, + }, + db, +}; 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 } } pub async fn process_transport_inbound(&mut self, req: ClientRequest) -> Output { @@ -22,8 +38,46 @@ impl ClientSession { ClientError::MissingRequestPayload })?; - let _ = msg; - Err(ClientError::UnexpectedRequestPayload) + match msg { + ClientRequestPayload::EvmSignTransaction(sign_req) => { + let wallet_address: [u8; 20] = sign_req + .wallet_address + .try_into() + .map_err(|_| ClientError::UnexpectedRequestPayload)?; + + let mut rlp_bytes: &[u8] = &sign_req.rlp_transaction; + let tx = TxEip1559::decode(&mut rlp_bytes) + .map_err(|_| ClientError::UnexpectedRequestPayload)?; + + let result = self + .props + .actors + .evm + .ask(ClientSignTransaction { + client_id: self.client_id, + wallet_address: Address::from_slice(&wallet_address), + transaction: tx, + }) + .await; + + let response_result = match result { + Ok(signature) => SignResult::Signature(signature.as_bytes().to_vec()), + Err(err) => { + error!(?err, "client sign transaction failed"); + SignResult::Error(EvmError::Internal.into()) + } + }; + + Ok(ClientResponse { + payload: Some(ClientResponsePayload::EvmSignTransaction( + EvmSignTransactionResponse { + result: Some(response_result), + }, + )), + }) + } + _ => Err(ClientError::UnexpectedRequestPayload), + } } } @@ -89,6 +143,9 @@ impl ClientSession { use arbiter_proto::transport::DummyTransport; let transport: super::Transport = Box::new(DummyTransport::new()); let props = ClientConnection::new(db, transport, actors); - Self { props } + Self { + props, + client_id: 0, + } } } diff --git a/server/crates/arbiter-server/src/actors/router/mod.rs b/server/crates/arbiter-server/src/actors/router/mod.rs index a0a75b8..8d06152 100644 --- a/server/crates/arbiter-server/src/actors/router/mod.rs +++ b/server/crates/arbiter-server/src/actors/router/mod.rs @@ -1,20 +1,14 @@ use std::{collections::HashMap, ops::ControlFlow}; -use ed25519_dalek::VerifyingKey; use kameo::{ Actor, actor::{ActorId, ActorRef}, messages, prelude::{ActorStopReason, Context, WeakActorRef}, - reply::DelegatedReply, }; -use tokio::{sync::watch, task::JoinSet}; -use tracing::{info, warn}; +use tracing::info; -use crate::actors::{ - client::session::ClientSession, - user_agent::session::{RequestNewClientApproval, UserAgentSession}, -}; +use crate::actors::{client::session::ClientSession, user_agent::session::UserAgentSession}; #[derive(Default)] pub struct MessageRouter { @@ -56,73 +50,6 @@ impl Actor for MessageRouter { } } -#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq, Hash)] -pub enum ApprovalError { - #[error("No user agents connected")] - NoUserAgentsConnected, -} - -async fn request_client_approval( - user_agents: &[WeakActorRef], - client_pubkey: VerifyingKey, -) -> Result { - if user_agents.is_empty() { - return Err(ApprovalError::NoUserAgentsConnected); - } - - let mut pool = JoinSet::new(); - let (cancel_tx, cancel_rx) = watch::channel(()); - - for weak_ref in user_agents { - match weak_ref.upgrade() { - Some(agent) => { - let cancel_rx = cancel_rx.clone(); - pool.spawn(async move { - agent - .ask(RequestNewClientApproval { - client_pubkey, - cancel_flag: cancel_rx.clone(), - }) - .await - }); - } - None => { - warn!( - id = weak_ref.id().to_string(), - actor = "MessageRouter", - event = "useragent.disconnected_before_approval" - ); - } - } - } - - while let Some(result) = pool.join_next().await { - match result { - Ok(Ok(approved)) => { - // cancel other pending requests - let _ = cancel_tx.send(()); - return Ok(approved); - } - Ok(Err(err)) => { - warn!( - ?err, - actor = "MessageRouter", - event = "useragent.approval_error" - ); - } - Err(err) => { - warn!( - ?err, - actor = "MessageRouter", - event = "useragent.approval_task_failed" - ); - } - } - } - - Err(ApprovalError::NoUserAgentsConnected) -} - #[messages] impl MessageRouter { #[message(ctx)] @@ -146,29 +73,4 @@ impl MessageRouter { ctx.actor_ref().link(&actor).await; self.clients.insert(actor.id(), actor); } - - #[message(ctx)] - pub async fn request_client_approval( - &mut self, - client_pubkey: VerifyingKey, - ctx: &mut Context>>, - ) -> DelegatedReply> { - let (reply, Some(reply_sender)) = ctx.reply_sender() else { - panic!("Exptected `request_client_approval` to have callback channel"); - }; - - let weak_refs = self - .user_agents - .values() - .map(|agent| agent.downgrade()) - .collect::>(); - - // handle in subtask to not to lock the actor - tokio::task::spawn(async move { - let result = request_client_approval(&weak_refs, client_pubkey).await; - reply_sender.send(result); - }); - - reply - } } diff --git a/server/crates/arbiter-server/src/actors/user_agent/session.rs b/server/crates/arbiter-server/src/actors/user_agent/session.rs index b686796..a19e85b 100644 --- a/server/crates/arbiter-server/src/actors/user_agent/session.rs +++ b/server/crates/arbiter-server/src/actors/user_agent/session.rs @@ -3,25 +3,32 @@ use std::{ops::DerefMut, sync::Mutex}; use arbiter_proto::proto::{ evm as evm_proto, user_agent::{ - ClientConnectionCancel, ClientConnectionRequest, UnsealEncryptedKey, UnsealResult, + SdkClientApproveRequest, SdkClientApproveResponse, SdkClientEntry, + SdkClientError as ProtoSdkClientError, SdkClientList, SdkClientListResponse, + SdkClientRevokeRequest, SdkClientRevokeResponse, UnsealEncryptedKey, UnsealResult, UnsealStart, UnsealStartResponse, UserAgentRequest, UserAgentResponse, + sdk_client_approve_response, sdk_client_list_response, sdk_client_revoke_response, user_agent_request::Payload as UserAgentRequestPayload, user_agent_response::Payload as UserAgentResponsePayload, }, }; use chacha20poly1305::{AeadInPlace, XChaCha20Poly1305, XNonce, aead::KeyInit}; -use ed25519_dalek::VerifyingKey; -use kameo::{Actor, error::SendError, messages, prelude::Context}; +use diesel::{ExpressionMethods as _, QueryDsl as _, dsl::insert_into}; +use diesel_async::RunQueryDsl as _; +use kameo::{Actor, error::SendError, prelude::Context}; use memsafe::MemSafe; -use tokio::{select, sync::watch}; +use tokio::select; use tracing::{error, info}; use x25519_dalek::{EphemeralSecret, PublicKey}; -use crate::actors::{ - evm::{Generate, ListWallets}, - keyholder::{self, TryUnseal}, - router::RegisterUserAgent, - user_agent::{TransportResponseError, UserAgentConnection}, +use crate::{ + actors::{ + evm::{Generate, ListWallets}, + keyholder::{self, TryUnseal}, + router::RegisterUserAgent, + user_agent::{TransportResponseError, UserAgentConnection}, + }, + db::schema::program_client, }; mod state; @@ -108,52 +115,6 @@ impl UserAgentSession { } } -#[messages] -impl UserAgentSession { - // TODO: Think about refactoring it to state-machine based flow, as we already have one - #[message(ctx)] - pub async fn request_new_client_approval( - &mut self, - client_pubkey: VerifyingKey, - mut cancel_flag: watch::Receiver<()>, - ctx: &mut Context>, - ) -> Result { - self.send_msg( - UserAgentResponsePayload::ClientConnectionRequest(ClientConnectionRequest { - pubkey: client_pubkey.as_bytes().to_vec(), - }), - ctx, - ) - .await?; - - let extractor = |msg| { - if let UserAgentRequestPayload::ClientConnectionResponse(client_connection_response) = - msg - { - Some(client_connection_response) - } else { - None - } - }; - - tokio::select! { - _ = cancel_flag.changed() => { - info!(actor = "useragent", "client connection approval cancelled"); - self.send_msg( - UserAgentResponsePayload::ClientConnectionCancel(ClientConnectionCancel {}), - ctx, - ).await?; - Ok(false) - } - result = self.expect_msg(extractor, ctx) => { - let result = result?; - info!(actor = "useragent", "received client connection approval result: approved={}", result.approved); - Ok(result.approved) - } - } - } -} - impl UserAgentSession { pub async fn process_transport_inbound(&mut self, req: UserAgentRequest) -> Output { let msg = req.payload.ok_or_else(|| { @@ -170,6 +131,13 @@ impl UserAgentSession { } UserAgentRequestPayload::EvmWalletCreate(_) => self.handle_evm_wallet_create().await, UserAgentRequestPayload::EvmWalletList(_) => self.handle_evm_wallet_list().await, + UserAgentRequestPayload::SdkClientApprove(req) => { + self.handle_sdk_client_approve(req).await + } + UserAgentRequestPayload::SdkClientRevoke(req) => { + self.handle_sdk_client_revoke(req).await + } + UserAgentRequestPayload::SdkClientList(_) => self.handle_sdk_client_list().await, _ => Err(TransportResponseError::UnexpectedRequestPayload), } } @@ -331,6 +299,204 @@ impl UserAgentSession { } } +impl UserAgentSession { + async fn handle_sdk_client_approve(&mut self, req: SdkClientApproveRequest) -> Output { + use sdk_client_approve_response::Result as ApproveResult; + + if req.pubkey.len() != 32 { + return Ok(response(UserAgentResponsePayload::SdkClientApprove( + SdkClientApproveResponse { + result: Some(ApproveResult::Error(ProtoSdkClientError::Internal.into())), + }, + ))); + } + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i32; + + let mut conn = match self.props.db.get().await { + Ok(c) => c, + Err(e) => { + error!(?e, "Failed to get DB connection for sdk_client_approve"); + return Ok(response(UserAgentResponsePayload::SdkClientApprove( + SdkClientApproveResponse { + result: Some(ApproveResult::Error(ProtoSdkClientError::Internal.into())), + }, + ))); + } + }; + + let pubkey_bytes = req.pubkey.clone(); + let insert_result = insert_into(program_client::table) + .values(( + program_client::public_key.eq(&pubkey_bytes), + program_client::nonce.eq(1), // pre-incremented; challenge will use nonce=0 + program_client::created_at.eq(now), + program_client::updated_at.eq(now), + )) + .execute(&mut conn) + .await; + + match insert_result { + Ok(_) => { + match program_client::table + .filter(program_client::public_key.eq(&pubkey_bytes)) + .order(program_client::id.desc()) + .select(( + program_client::id, + program_client::public_key, + program_client::created_at, + )) + .first::<(i32, Vec, i32)>(&mut conn) + .await + { + Ok((id, pubkey, created_at)) => Ok(response( + UserAgentResponsePayload::SdkClientApprove(SdkClientApproveResponse { + result: Some(ApproveResult::Client(SdkClientEntry { + id, + pubkey, + created_at, + })), + }), + )), + Err(e) => { + error!(?e, "Failed to fetch inserted SDK client"); + Ok(response(UserAgentResponsePayload::SdkClientApprove( + SdkClientApproveResponse { + result: Some(ApproveResult::Error( + ProtoSdkClientError::Internal.into(), + )), + }, + ))) + } + } + } + Err(diesel::result::Error::DatabaseError( + diesel::result::DatabaseErrorKind::UniqueViolation, + _, + )) => Ok(response(UserAgentResponsePayload::SdkClientApprove( + SdkClientApproveResponse { + result: Some(ApproveResult::Error( + ProtoSdkClientError::AlreadyExists.into(), + )), + }, + ))), + Err(e) => { + error!(?e, "Failed to insert SDK client"); + Ok(response(UserAgentResponsePayload::SdkClientApprove( + SdkClientApproveResponse { + result: Some(ApproveResult::Error(ProtoSdkClientError::Internal.into())), + }, + ))) + } + } + } + + async fn handle_sdk_client_list(&mut self) -> Output { + let mut conn = match self.props.db.get().await { + Ok(c) => c, + Err(e) => { + error!(?e, "Failed to get DB connection for sdk_client_list"); + return Ok(response(UserAgentResponsePayload::SdkClientList( + SdkClientListResponse { + result: Some(sdk_client_list_response::Result::Error( + ProtoSdkClientError::Internal.into(), + )), + }, + ))); + } + }; + + match program_client::table + .select(( + program_client::id, + program_client::public_key, + program_client::created_at, + )) + .load::<(i32, Vec, i32)>(&mut conn) + .await + { + Ok(rows) => Ok(response(UserAgentResponsePayload::SdkClientList( + SdkClientListResponse { + result: Some(sdk_client_list_response::Result::Clients(SdkClientList { + clients: rows + .into_iter() + .map(|(id, pubkey, created_at)| SdkClientEntry { + id, + pubkey, + created_at, + }) + .collect(), + })), + }, + ))), + Err(e) => { + error!(?e, "Failed to list SDK clients"); + Ok(response(UserAgentResponsePayload::SdkClientList( + SdkClientListResponse { + result: Some(sdk_client_list_response::Result::Error( + ProtoSdkClientError::Internal.into(), + )), + }, + ))) + } + } + } + + async fn handle_sdk_client_revoke(&mut self, req: SdkClientRevokeRequest) -> Output { + use sdk_client_revoke_response::Result as RevokeResult; + + let mut conn = match self.props.db.get().await { + Ok(c) => c, + Err(e) => { + error!(?e, "Failed to get DB connection for sdk_client_revoke"); + return Ok(response(UserAgentResponsePayload::SdkClientRevoke( + SdkClientRevokeResponse { + result: Some(RevokeResult::Error(ProtoSdkClientError::Internal.into())), + }, + ))); + } + }; + + match diesel::delete(program_client::table) + .filter(program_client::id.eq(req.client_id)) + .execute(&mut conn) + .await + { + Ok(0) => Ok(response(UserAgentResponsePayload::SdkClientRevoke( + SdkClientRevokeResponse { + result: Some(RevokeResult::Error(ProtoSdkClientError::NotFound.into())), + }, + ))), + Ok(_) => Ok(response(UserAgentResponsePayload::SdkClientRevoke( + SdkClientRevokeResponse { + result: Some(RevokeResult::Ok(())), + }, + ))), + Err(diesel::result::Error::DatabaseError( + diesel::result::DatabaseErrorKind::ForeignKeyViolation, + _, + )) => Ok(response(UserAgentResponsePayload::SdkClientRevoke( + SdkClientRevokeResponse { + result: Some(RevokeResult::Error( + ProtoSdkClientError::HasRelatedData.into(), + )), + }, + ))), + Err(e) => { + error!(?e, "Failed to delete SDK client"); + Ok(response(UserAgentResponsePayload::SdkClientRevoke( + SdkClientRevokeResponse { + result: Some(RevokeResult::Error(ProtoSdkClientError::Internal.into())), + }, + ))) + } + } + } +} + fn map_evm_error(op: &str, err: SendError) -> evm_proto::EvmError { use crate::actors::{evm::Error as EvmError, keyholder::Error as KhError}; match err { diff --git a/server/crates/arbiter-server/src/lib.rs b/server/crates/arbiter-server/src/lib.rs index d712992..abb51a5 100644 --- a/server/crates/arbiter-server/src/lib.rs +++ b/server/crates/arbiter-server/src/lib.rs @@ -79,7 +79,7 @@ fn client_auth_error_status(value: &client::auth::Error) -> Status { Status::invalid_argument("Failed to convert pubkey to VerifyingKey") } Error::InvalidChallengeSolution => Status::unauthenticated(value.to_string()), - Error::ApproveError(_) => Status::permission_denied(value.to_string()), + Error::NotRegistered => Status::permission_denied(value.to_string()), Error::Transport => Status::internal("Transport error"), Error::DatabasePoolUnavailable => Status::internal("Database pool error"), Error::DatabaseOperationFailed => Status::internal("Database error"), diff --git a/server/crates/arbiter-server/tests/client/auth.rs b/server/crates/arbiter-server/tests/client/auth.rs index 6228a58..5d82423 100644 --- a/server/crates/arbiter-server/tests/client/auth.rs +++ b/server/crates/arbiter-server/tests/client/auth.rs @@ -1,7 +1,15 @@ -use arbiter_proto::proto::client::{ - AuthChallengeRequest, AuthChallengeSolution, ClientRequest, - client_request::Payload as ClientRequestPayload, - client_response::Payload as ClientResponsePayload, +use alloy::{ + consensus::TxEip1559, + primitives::{Address, Bytes, TxKind, U256}, + rlp::Encodable, +}; +use arbiter_proto::proto::{ + client::{ + AuthChallengeRequest, AuthChallengeSolution, ClientRequest, + client_request::Payload as ClientRequestPayload, + client_response::Payload as ClientResponsePayload, + }, + evm::EvmSignTransactionRequest, }; use arbiter_proto::transport::Bi; use arbiter_server::actors::GlobalActors; @@ -109,3 +117,106 @@ pub async fn test_challenge_auth() { // Auth completes, session spawned task.await.unwrap(); } + +#[tokio::test] +#[test_log::test] +pub async fn test_evm_sign_request_payload_is_handled() { + let db = db::create_test_pool().await; + + let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng()); + let pubkey_bytes = new_key.verifying_key().to_bytes().to_vec(); + + { + let mut conn = db.get().await.unwrap(); + insert_into(schema::program_client::table) + .values(schema::program_client::public_key.eq(pubkey_bytes.clone())) + .execute(&mut conn) + .await + .unwrap(); + } + + let (server_transport, mut test_transport) = ChannelTransport::new(); + let actors = GlobalActors::spawn(db.clone()).await.unwrap(); + + let props = ClientConnection::new(db.clone(), Box::new(server_transport), actors); + let task = tokio::spawn(connect_client(props)); + + test_transport + .send(ClientRequest { + payload: Some(ClientRequestPayload::AuthChallengeRequest( + AuthChallengeRequest { + pubkey: pubkey_bytes, + }, + )), + }) + .await + .unwrap(); + + let response = test_transport + .recv() + .await + .expect("should receive challenge"); + let challenge = match response { + Ok(resp) => match resp.payload { + Some(ClientResponsePayload::AuthChallenge(c)) => c, + other => panic!("Expected AuthChallenge, got {other:?}"), + }, + Err(err) => panic!("Expected Ok response, got Err({err:?})"), + }; + + let formatted_challenge = arbiter_proto::format_challenge(challenge.nonce, &challenge.pubkey); + let signature = new_key.sign(&formatted_challenge); + + test_transport + .send(ClientRequest { + payload: Some(ClientRequestPayload::AuthChallengeSolution( + AuthChallengeSolution { + signature: signature.to_bytes().to_vec(), + }, + )), + }) + .await + .unwrap(); + + task.await.unwrap(); + + let tx = TxEip1559 { + chain_id: 1, + nonce: 0, + gas_limit: 21_000, + max_fee_per_gas: 1, + max_priority_fee_per_gas: 1, + to: TxKind::Call(Address::from_slice(&[0x11; 20])), + value: U256::ZERO, + input: Bytes::new(), + access_list: Default::default(), + }; + + let mut rlp_transaction = Vec::new(); + tx.encode(&mut rlp_transaction); + + test_transport + .send(ClientRequest { + payload: Some(ClientRequestPayload::EvmSignTransaction( + EvmSignTransactionRequest { + wallet_address: [0x22; 20].to_vec(), + rlp_transaction, + }, + )), + }) + .await + .unwrap(); + + let response = test_transport + .recv() + .await + .expect("should receive sign response"); + + match response { + Ok(resp) => match resp.payload { + Some(ClientResponsePayload::EvmSignTransaction(_)) => {} + other => panic!("Expected EvmSignTransaction response, got {other:?}"), + }, + Err(err) => panic!("Expected Ok response, got Err({err:?})"), + } +} diff --git a/server/crates/arbiter-server/tests/user_agent.rs b/server/crates/arbiter-server/tests/user_agent.rs index dcd9789..355a721 100644 --- a/server/crates/arbiter-server/tests/user_agent.rs +++ b/server/crates/arbiter-server/tests/user_agent.rs @@ -2,5 +2,7 @@ mod common; #[path = "user_agent/auth.rs"] mod auth; +#[path = "user_agent/sdk_client.rs"] +mod sdk_client; #[path = "user_agent/unseal.rs"] mod unseal; diff --git a/server/crates/arbiter-server/tests/user_agent/sdk_client.rs b/server/crates/arbiter-server/tests/user_agent/sdk_client.rs new file mode 100644 index 0000000..3e2734a --- /dev/null +++ b/server/crates/arbiter-server/tests/user_agent/sdk_client.rs @@ -0,0 +1,270 @@ +use arbiter_proto::proto::user_agent::{ + SdkClientApproveRequest, SdkClientError as ProtoSdkClientError, SdkClientRevokeRequest, + UserAgentRequest, sdk_client_approve_response, sdk_client_list_response, + sdk_client_revoke_response, user_agent_request::Payload as UserAgentRequestPayload, + user_agent_response::Payload as UserAgentResponsePayload, +}; +use arbiter_server::{ + actors::{GlobalActors, user_agent::session::UserAgentSession}, + db, +}; + +/// Shared helper: create a session and register a client pubkey via sdk_client_approve. +async fn make_session(db: &db::DatabasePool) -> UserAgentSession { + let actors = GlobalActors::spawn(db.clone()).await.unwrap(); + UserAgentSession::new_test(db.clone(), actors) +} + +#[tokio::test] +#[test_log::test] +async fn test_sdk_client_approve_registers_client() { + let db = db::create_test_pool().await; + let mut session = make_session(&db).await; + + let pubkey = [0x42u8; 32]; + + let response = session + .process_transport_inbound(UserAgentRequest { + payload: Some(UserAgentRequestPayload::SdkClientApprove( + SdkClientApproveRequest { + pubkey: pubkey.to_vec(), + }, + )), + }) + .await + .expect("handler should succeed"); + + let entry = match response.payload.unwrap() { + UserAgentResponsePayload::SdkClientApprove(resp) => match resp.result.unwrap() { + sdk_client_approve_response::Result::Client(e) => e, + sdk_client_approve_response::Result::Error(e) => { + panic!("Expected Client, got error {:?}", e) + } + }, + other => panic!("Expected SdkClientApprove, got {other:?}"), + }; + + assert_eq!(entry.pubkey, pubkey.to_vec()); + assert!(entry.id > 0); +} + +#[tokio::test] +#[test_log::test] +async fn test_sdk_client_approve_duplicate_returns_already_exists() { + let db = db::create_test_pool().await; + let mut session = make_session(&db).await; + + let pubkey = [0x11u8; 32]; + let req = UserAgentRequest { + payload: Some(UserAgentRequestPayload::SdkClientApprove( + SdkClientApproveRequest { + pubkey: pubkey.to_vec(), + }, + )), + }; + + session + .process_transport_inbound(req.clone()) + .await + .unwrap(); + + let response = session + .process_transport_inbound(req) + .await + .expect("second insert should not panic"); + + match response.payload.unwrap() { + UserAgentResponsePayload::SdkClientApprove(resp) => match resp.result.unwrap() { + sdk_client_approve_response::Result::Error(code) => { + assert_eq!(code, ProtoSdkClientError::AlreadyExists as i32); + } + sdk_client_approve_response::Result::Client(_) => { + panic!("Expected AlreadyExists error for duplicate pubkey") + } + }, + other => panic!("Expected SdkClientApprove, got {other:?}"), + } +} + +#[tokio::test] +#[test_log::test] +async fn test_sdk_client_list_shows_registered_clients() { + let db = db::create_test_pool().await; + let mut session = make_session(&db).await; + + let pubkey_a = [0x0Au8; 32]; + let pubkey_b = [0x0Bu8; 32]; + + for pubkey in [pubkey_a, pubkey_b] { + session + .process_transport_inbound(UserAgentRequest { + payload: Some(UserAgentRequestPayload::SdkClientApprove( + SdkClientApproveRequest { + pubkey: pubkey.to_vec(), + }, + )), + }) + .await + .unwrap(); + } + + let response = session + .process_transport_inbound(UserAgentRequest { + payload: Some(UserAgentRequestPayload::SdkClientList(())), + }) + .await + .expect("list should succeed"); + + let clients = match response.payload.unwrap() { + UserAgentResponsePayload::SdkClientList(resp) => match resp.result.unwrap() { + sdk_client_list_response::Result::Clients(list) => list.clients, + sdk_client_list_response::Result::Error(e) => { + panic!("Expected Clients, got error {:?}", e) + } + }, + other => panic!("Expected SdkClientList, got {other:?}"), + }; + + assert_eq!(clients.len(), 2); + let pubkeys: Vec> = clients.into_iter().map(|e| e.pubkey).collect(); + assert!(pubkeys.contains(&pubkey_a.to_vec())); + assert!(pubkeys.contains(&pubkey_b.to_vec())); +} + +#[tokio::test] +#[test_log::test] +async fn test_sdk_client_revoke_removes_client() { + let db = db::create_test_pool().await; + let mut session = make_session(&db).await; + + let pubkey = [0xBBu8; 32]; + + // Register a client and get its id + let approve_response = session + .process_transport_inbound(UserAgentRequest { + payload: Some(UserAgentRequestPayload::SdkClientApprove( + SdkClientApproveRequest { + pubkey: pubkey.to_vec(), + }, + )), + }) + .await + .unwrap(); + + let client_id = match approve_response.payload.unwrap() { + UserAgentResponsePayload::SdkClientApprove(resp) => match resp.result.unwrap() { + sdk_client_approve_response::Result::Client(e) => e.id, + sdk_client_approve_response::Result::Error(e) => panic!("approve failed: {:?}", e), + }, + other => panic!("{other:?}"), + }; + + // Revoke the client + let revoke_response = session + .process_transport_inbound(UserAgentRequest { + payload: Some(UserAgentRequestPayload::SdkClientRevoke( + SdkClientRevokeRequest { client_id }, + )), + }) + .await + .expect("revoke should succeed"); + + match revoke_response.payload.unwrap() { + UserAgentResponsePayload::SdkClientRevoke(resp) => match resp.result.unwrap() { + sdk_client_revoke_response::Result::Ok(_) => {} + sdk_client_revoke_response::Result::Error(e) => { + panic!("Expected Ok, got error {:?}", e) + } + }, + other => panic!("Expected SdkClientRevoke, got {other:?}"), + } + + // List should now be empty + let list_response = session + .process_transport_inbound(UserAgentRequest { + payload: Some(UserAgentRequestPayload::SdkClientList(())), + }) + .await + .unwrap(); + + let clients = match list_response.payload.unwrap() { + UserAgentResponsePayload::SdkClientList(resp) => match resp.result.unwrap() { + sdk_client_list_response::Result::Clients(list) => list.clients, + sdk_client_list_response::Result::Error(e) => panic!("list error: {:?}", e), + }, + other => panic!("{other:?}"), + }; + assert!(clients.is_empty(), "client should be removed after revoke"); +} + +#[tokio::test] +#[test_log::test] +async fn test_sdk_client_revoke_not_found_returns_error() { + let db = db::create_test_pool().await; + let mut session = make_session(&db).await; + + let response = session + .process_transport_inbound(UserAgentRequest { + payload: Some(UserAgentRequestPayload::SdkClientRevoke( + SdkClientRevokeRequest { client_id: 9999 }, + )), + }) + .await + .unwrap(); + + match response.payload.unwrap() { + UserAgentResponsePayload::SdkClientRevoke(resp) => match resp.result.unwrap() { + sdk_client_revoke_response::Result::Error(code) => { + assert_eq!(code, ProtoSdkClientError::NotFound as i32); + } + sdk_client_revoke_response::Result::Ok(_) => { + panic!("Expected NotFound error for missing client_id") + } + }, + other => panic!("Expected SdkClientRevoke, got {other:?}"), + } +} + +#[tokio::test] +#[test_log::test] +async fn test_sdk_client_approve_rejected_client_cannot_auth() { + // Verify the core flow: only pre-approved clients can authenticate + use arbiter_proto::proto::client::{ + AuthChallengeRequest, ClientRequest, client_request::Payload as ClientRequestPayload, + client_response::Payload as ClientResponsePayload, + }; + use arbiter_proto::transport::Bi as _; + use arbiter_server::actors::client::{ClientConnection, connect_client}; + + 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(); + + let (server_transport, mut test_transport) = super::common::ChannelTransport::<_, _>::new(); + let props = ClientConnection::new(db.clone(), Box::new(server_transport), actors.clone()); + let task = tokio::spawn(connect_client(props)); + + test_transport + .send(ClientRequest { + payload: Some(ClientRequestPayload::AuthChallengeRequest( + AuthChallengeRequest { + pubkey: pubkey_bytes.clone(), + }, + )), + }) + .await + .unwrap(); + + let response = test_transport.recv().await.unwrap().unwrap(); + assert!( + matches!( + response.payload.unwrap(), + ClientResponsePayload::ClientConnectError(_) + ), + "unregistered client should be rejected" + ); + + task.await.unwrap(); +} diff --git a/server/crates/arbiter-terrors-poc/Cargo.toml b/server/crates/arbiter-terrors-poc/Cargo.toml new file mode 100644 index 0000000..127adb1 --- /dev/null +++ b/server/crates/arbiter-terrors-poc/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "arbiter-terrors-poc" +version = "0.1.0" +edition = "2024" + +[dependencies] +terrors = "0.3" diff --git a/server/crates/arbiter-terrors-poc/src/errors.rs b/server/crates/arbiter-terrors-poc/src/errors.rs new file mode 100644 index 0000000..0ef0061 --- /dev/null +++ b/server/crates/arbiter-terrors-poc/src/errors.rs @@ -0,0 +1,84 @@ +use terrors::OneOf; + +// Wire boundary type — what would go into a proto response +#[derive(Debug)] +pub enum ProtoError { + NotRegistered, + InvalidSignature, + Internal(String), +} + +// Internal terrors types +pub struct NotRegistered; +pub struct InvalidSignature; +pub struct Internal(pub String); + +impl From for ProtoError { + fn from(_: NotRegistered) -> Self { + ProtoError::NotRegistered + } +} + +impl From for ProtoError { + fn from(_: InvalidSignature) -> Self { + ProtoError::InvalidSignature + } +} + +impl From for ProtoError { + fn from(e: Internal) -> Self { + ProtoError::Internal(e.0) + } +} + +// Converts the narrowed remainder after handling NotRegistered +impl From> for ProtoError { + fn from(e: OneOf<(InvalidSignature, Internal)>) -> Self { + match e.narrow::() { + Ok(_) => ProtoError::InvalidSignature, + Err(e) => { + let Internal(msg) = e.take(); + ProtoError::Internal(msg) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn not_registered_converts_to_proto() { + let e: ProtoError = NotRegistered.into(); + assert!(matches!(e, ProtoError::NotRegistered)); + } + + #[test] + fn invalid_signature_converts_to_proto() { + let e: ProtoError = InvalidSignature.into(); + assert!(matches!(e, ProtoError::InvalidSignature)); + } + + #[test] + fn internal_converts_to_proto() { + let e: ProtoError = Internal("boom".into()).into(); + assert!(matches!(e, ProtoError::Internal(msg) if msg == "boom")); + } + + #[test] + fn one_of_remainder_converts_to_proto_invalid_signature() { + use terrors::OneOf; + let e: OneOf<(InvalidSignature, Internal)> = OneOf::new(InvalidSignature); + let proto = ProtoError::from(e); + assert!(matches!(proto, ProtoError::InvalidSignature)); + } + + #[test] + fn one_of_remainder_converts_to_proto_internal() { + use terrors::OneOf; + let e: OneOf<(InvalidSignature, Internal)> = OneOf::new(Internal("db fail".into())); + let proto = ProtoError::from(e); + assert!(matches!(proto, ProtoError::Internal(msg) if msg == "db fail")); + } +} diff --git a/server/crates/arbiter-terrors-poc/src/main.rs b/server/crates/arbiter-terrors-poc/src/main.rs new file mode 100644 index 0000000..f18efb5 --- /dev/null +++ b/server/crates/arbiter-terrors-poc/src/main.rs @@ -0,0 +1,3 @@ +mod errors; + +fn main() {} From 3360d3c8c784dbe028afc41754b7e2dfe4f67b49 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Sun, 15 Mar 2026 19:24:21 +0100 Subject: [PATCH 02/20] feat(poc): add db and auth modules with terrors error chains --- server/crates/arbiter-terrors-poc/src/auth.rs | 64 +++++++++++++++++++ server/crates/arbiter-terrors-poc/src/db.rs | 28 ++++++++ .../crates/arbiter-terrors-poc/src/errors.rs | 3 + server/crates/arbiter-terrors-poc/src/main.rs | 2 + 4 files changed, 97 insertions(+) create mode 100644 server/crates/arbiter-terrors-poc/src/auth.rs create mode 100644 server/crates/arbiter-terrors-poc/src/db.rs diff --git a/server/crates/arbiter-terrors-poc/src/auth.rs b/server/crates/arbiter-terrors-poc/src/auth.rs new file mode 100644 index 0000000..d4e352a --- /dev/null +++ b/server/crates/arbiter-terrors-poc/src/auth.rs @@ -0,0 +1,64 @@ +use terrors::OneOf; +use crate::errors::{Internal, InvalidSignature, NotRegistered}; + +pub fn verify_signature(_nonce: u32, sig: &str) -> Result<(), OneOf<(InvalidSignature,)>> { + if sig != "ok" { + return Err(OneOf::new(InvalidSignature)); + } + Ok(()) +} + +pub fn authenticate( + id: u32, + sig: &str, +) -> Result> { + if id == 0 { + return Err(OneOf::new(NotRegistered)); + } + + let nonce = crate::db::get_nonce(id) + .map_err(|e| e.broaden::<(NotRegistered, InvalidSignature, Internal), _>())?; + verify_signature(nonce, sig) + .map_err(|e| e.broaden::<(NotRegistered, InvalidSignature, Internal), _>())?; + + Ok(nonce) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn verify_signature_ok() { + assert!(verify_signature(42, "ok").is_ok()); + } + + #[test] + fn verify_signature_bad() { + let err = verify_signature(42, "bad").unwrap_err(); + assert!(err.narrow::().is_ok()); + } + + #[test] + fn authenticate_success() { + assert_eq!(authenticate(1, "ok").unwrap(), 42); + } + + #[test] + fn authenticate_not_registered() { + let err = authenticate(0, "ok").unwrap_err(); + assert!(err.narrow::().is_ok()); + } + + #[test] + fn authenticate_invalid_signature() { + let err = authenticate(1, "bad").unwrap_err(); + assert!(err.narrow::().is_ok()); + } + + #[test] + fn authenticate_internal_error() { + let err = authenticate(99, "ok").unwrap_err(); + assert!(err.narrow::().is_ok()); + } +} diff --git a/server/crates/arbiter-terrors-poc/src/db.rs b/server/crates/arbiter-terrors-poc/src/db.rs new file mode 100644 index 0000000..b025505 --- /dev/null +++ b/server/crates/arbiter-terrors-poc/src/db.rs @@ -0,0 +1,28 @@ +use terrors::OneOf; +use crate::errors::Internal; + +// Simulates fetching a nonce from a database. +// id=99 is a sentinel that triggers an Internal error. +pub fn get_nonce(id: u32) -> Result> { + if id == 99 { + return Err(OneOf::new(Internal("db pool unavailable".into()))); + } + Ok(42) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn get_nonce_returns_nonce_for_valid_id() { + assert_eq!(get_nonce(1).unwrap(), 42); + } + + #[test] + fn get_nonce_returns_internal_error_for_sentinel() { + let err = get_nonce(99).unwrap_err(); + let internal = err.take::(); + assert_eq!(internal.0, "db pool unavailable"); + } +} diff --git a/server/crates/arbiter-terrors-poc/src/errors.rs b/server/crates/arbiter-terrors-poc/src/errors.rs index 0ef0061..b49d15b 100644 --- a/server/crates/arbiter-terrors-poc/src/errors.rs +++ b/server/crates/arbiter-terrors-poc/src/errors.rs @@ -9,8 +9,11 @@ pub enum ProtoError { } // Internal terrors types +#[derive(Debug)] pub struct NotRegistered; +#[derive(Debug)] pub struct InvalidSignature; +#[derive(Debug)] pub struct Internal(pub String); impl From for ProtoError { diff --git a/server/crates/arbiter-terrors-poc/src/main.rs b/server/crates/arbiter-terrors-poc/src/main.rs index f18efb5..a3bef62 100644 --- a/server/crates/arbiter-terrors-poc/src/main.rs +++ b/server/crates/arbiter-terrors-poc/src/main.rs @@ -1,3 +1,5 @@ mod errors; +mod db; +mod auth; fn main() {} From 66026e903a8e906371d333412101acb152147f66 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Sun, 15 Mar 2026 19:24:49 +0100 Subject: [PATCH 03/20] feat(poc): complete terrors PoC with main scenarios --- server/crates/arbiter-terrors-poc/src/main.rs | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/server/crates/arbiter-terrors-poc/src/main.rs b/server/crates/arbiter-terrors-poc/src/main.rs index a3bef62..d8e2f7a 100644 --- a/server/crates/arbiter-terrors-poc/src/main.rs +++ b/server/crates/arbiter-terrors-poc/src/main.rs @@ -1,5 +1,26 @@ -mod errors; -mod db; mod auth; +mod db; +mod errors; -fn main() {} +use errors::ProtoError; + +fn run(id: u32, sig: &str) { + print!("authenticate(id={id}, sig={sig:?}) => "); + match auth::authenticate(id, sig) { + Ok(nonce) => println!("Ok(nonce={nonce})"), + Err(e) => match e.narrow::() { + Ok(_) => println!("Err(NotRegistered) — handled locally"), + Err(remaining) => { + let proto = ProtoError::from(remaining); + println!("Err(ProtoError::{proto:?}) — forwarded to wire"); + } + }, + } +} + +fn main() { + run(0, "ok"); // NotRegistered + run(1, "bad"); // InvalidSignature + run(99, "ok"); // Internal + run(1, "ok"); // success +} From 099f76166e92da89b711eadcdbd34ad18598e3cd Mon Sep 17 00:00:00 2001 From: CleverWild Date: Sun, 15 Mar 2026 21:11:23 +0100 Subject: [PATCH 04/20] feat(PoC): terrors crate usage --- server/Cargo.lock | 13 +++ server/crates/arbiter-terrors-poc/src/auth.rs | 97 ++++++++++++++++--- server/crates/arbiter-terrors-poc/src/db.rs | 26 +++-- .../crates/arbiter-terrors-poc/src/errors.rs | 73 +++++++++++--- server/crates/arbiter-terrors-poc/src/main.rs | 27 +++++- 5 files changed, 197 insertions(+), 39 deletions(-) diff --git a/server/Cargo.lock b/server/Cargo.lock index 2d0b912..061f1a2 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -761,6 +761,13 @@ dependencies = [ "zeroize", ] +[[package]] +name = "arbiter-terrors-poc" +version = "0.1.0" +dependencies = [ + "terrors", +] + [[package]] name = "arbiter-tokens-registry" version = "0.1.0" @@ -4856,6 +4863,12 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "terrors" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "987fd8c678ca950df2a18b2c6e9da6ca511d449278fab3565efe0d49c0c07a5d" + [[package]] name = "test-log" version = "0.2.19" diff --git a/server/crates/arbiter-terrors-poc/src/auth.rs b/server/crates/arbiter-terrors-poc/src/auth.rs index d4e352a..b9f6148 100644 --- a/server/crates/arbiter-terrors-poc/src/auth.rs +++ b/server/crates/arbiter-terrors-poc/src/auth.rs @@ -1,5 +1,35 @@ +use crate::errors::{InternalError1, InternalError2, InvalidSignature, NotRegistered}; use terrors::OneOf; -use crate::errors::{Internal, InvalidSignature, NotRegistered}; + +use crate::errors::ProtoError; + +// Each sub-call's error type already implements DrainInto, so we convert +// directly to ProtoError without broaden — no turbofish needed anywhere. +// +// Call chain: +// load_config() → OneOf<(InternalError2,)> → ProtoError::from +// get_nonce() → OneOf<(InternalError1, InternalError2)> → ProtoError::from +// verify_sig() → OneOf<(InvalidSignature,)> → ProtoError::from +pub fn process_request(id: u32, sig: &str) -> Result { + if id == 0 { + return Err(ProtoError::NotRegistered); + } + + let config = load_config(id).map_err(ProtoError::from)?; + let nonce = crate::db::get_nonce(id).map_err(ProtoError::from)?; + verify_signature(nonce, sig).map_err(ProtoError::from)?; + + Ok(format!("config={config} nonce={nonce} sig={sig}")) +} + +// Simulates loading a config value. +// id=97 triggers InternalError2 ("config read failed"). +fn load_config(id: u32) -> Result> { + if id == 97 { + return Err(OneOf::new(InternalError2("config read failed".to_owned()))); + } + Ok(format!("cfg-{id}")) +} pub fn verify_signature(_nonce: u32, sig: &str) -> Result<(), OneOf<(InvalidSignature,)>> { if sig != "ok" { @@ -8,18 +38,21 @@ pub fn verify_signature(_nonce: u32, sig: &str) -> Result<(), OneOf<(InvalidSign Ok(()) } -pub fn authenticate( - id: u32, - sig: &str, -) -> Result> { +type AuthError = OneOf<( + NotRegistered, + InvalidSignature, + InternalError1, + InternalError2, +)>; + +pub fn authenticate(id: u32, sig: &str) -> Result { if id == 0 { return Err(OneOf::new(NotRegistered)); } - let nonce = crate::db::get_nonce(id) - .map_err(|e| e.broaden::<(NotRegistered, InvalidSignature, Internal), _>())?; - verify_signature(nonce, sig) - .map_err(|e| e.broaden::<(NotRegistered, InvalidSignature, Internal), _>())?; + // Return type AuthError lets the compiler infer the broaden target. + let nonce = crate::db::get_nonce(id).map_err(OneOf::broaden)?; + verify_signature(nonce, sig).map_err(OneOf::broaden)?; Ok(nonce) } @@ -57,8 +90,50 @@ mod tests { } #[test] - fn authenticate_internal_error() { + fn authenticate_internal_error1() { let err = authenticate(99, "ok").unwrap_err(); - assert!(err.narrow::().is_ok()); + assert!(err.narrow::().is_ok()); + } + + #[test] + fn authenticate_internal_error2() { + let err = authenticate(98, "ok").unwrap_err(); + assert!(err.narrow::().is_ok()); + } + + #[test] + fn process_request_success() { + let result = process_request(1, "ok").unwrap(); + assert!(result.contains("nonce=42")); + } + + #[test] + fn process_request_not_registered() { + let err = process_request(0, "ok").unwrap_err(); + assert!(matches!(err, crate::errors::ProtoError::NotRegistered)); + } + + #[test] + fn process_request_invalid_signature() { + let err = process_request(1, "bad").unwrap_err(); + assert!(matches!(err, crate::errors::ProtoError::InvalidSignature)); + } + + #[test] + fn process_request_internal_from_config() { + // id=97 → load_config returns InternalError2 + let err = process_request(97, "ok").unwrap_err(); + assert!( + matches!(err, crate::errors::ProtoError::Internal(ref msg) if msg == "config read failed") + ); + } + + #[test] + fn process_request_internal_from_db() { + // id=99 → get_nonce returns InternalError1 + let err = process_request(99, "ok").unwrap_err(); + assert!( + matches!(err, crate::errors::ProtoError::Internal(ref msg) if msg == "db pool unavailable") + ); } } diff --git a/server/crates/arbiter-terrors-poc/src/db.rs b/server/crates/arbiter-terrors-poc/src/db.rs index b025505..c2c4c5a 100644 --- a/server/crates/arbiter-terrors-poc/src/db.rs +++ b/server/crates/arbiter-terrors-poc/src/db.rs @@ -1,13 +1,15 @@ +use crate::errors::{InternalError1, InternalError2}; use terrors::OneOf; -use crate::errors::Internal; // Simulates fetching a nonce from a database. -// id=99 is a sentinel that triggers an Internal error. -pub fn get_nonce(id: u32) -> Result> { - if id == 99 { - return Err(OneOf::new(Internal("db pool unavailable".into()))); +// id=99 → InternalError1 (pool unavailable) +// id=98 → InternalError2 (query timeout) +pub fn get_nonce(id: u32) -> Result> { + match id { + 99 => Err(OneOf::new(InternalError1("db pool unavailable".to_owned()))), + 98 => Err(OneOf::new(InternalError2("query timeout".to_owned()))), + _ => Ok(42), } - Ok(42) } #[cfg(test)] @@ -20,9 +22,17 @@ mod tests { } #[test] - fn get_nonce_returns_internal_error_for_sentinel() { + fn get_nonce_returns_internal_error1_for_sentinel() { let err = get_nonce(99).unwrap_err(); - let internal = err.take::(); + let internal = err.narrow::().unwrap(); assert_eq!(internal.0, "db pool unavailable"); } + + #[test] + fn get_nonce_returns_internal_error2_for_sentinel() { + let err = get_nonce(98).unwrap_err(); + let e = err.narrow::().unwrap_err(); + let internal = e.take::(); + assert_eq!(internal.0, "query timeout"); + } } diff --git a/server/crates/arbiter-terrors-poc/src/errors.rs b/server/crates/arbiter-terrors-poc/src/errors.rs index b49d15b..cb436e1 100644 --- a/server/crates/arbiter-terrors-poc/src/errors.rs +++ b/server/crates/arbiter-terrors-poc/src/errors.rs @@ -5,7 +5,7 @@ use terrors::OneOf; pub enum ProtoError { NotRegistered, InvalidSignature, - Internal(String), + Internal(String), // Or Box, who cares? } // Internal terrors types @@ -14,8 +14,11 @@ pub struct NotRegistered; #[derive(Debug)] pub struct InvalidSignature; #[derive(Debug)] -pub struct Internal(pub String); +pub struct InternalError1(pub String); +#[derive(Debug)] +pub struct InternalError2(pub String); +// Errors can be scattered across the codebase as long as they implement Into impl From for ProtoError { fn from(_: NotRegistered) -> Self { ProtoError::NotRegistered @@ -28,22 +31,61 @@ impl From for ProtoError { } } -impl From for ProtoError { - fn from(e: Internal) -> Self { +impl From for ProtoError { + fn from(e: InternalError1) -> Self { + ProtoError::Internal(e.0) + } +} +impl From for ProtoError { + fn from(e: InternalError2) -> Self { ProtoError::Internal(e.0) } } -// Converts the narrowed remainder after handling NotRegistered -impl From> for ProtoError { - fn from(e: OneOf<(InvalidSignature, Internal)>) -> Self { - match e.narrow::() { - Ok(_) => ProtoError::InvalidSignature, - Err(e) => { - let Internal(msg) = e.take(); - ProtoError::Internal(msg) +/// Private helper trait for converting from OneOf where each T can be converted +/// into the target type `O` by recursively narrowing until a match is found. +/// +/// IDK why this isn't already in terrors. +trait DrainInto: terrors::TypeSet + Sized { + fn drain(e: OneOf) -> O; +} + +macro_rules! impl_drain_into { + ($head:ident) => { + impl<$head, O> DrainInto for ($head,) + where + $head: Into + 'static, + { + fn drain(e: OneOf<($head,)>) -> O { + e.take().into() } } + }; + ($head:ident, $($tail:ident),+) => { + impl<$head, $($tail),+, O> DrainInto for ($head, $($tail),+) + where + $head: Into + 'static, + ($($tail,)+): DrainInto, + { + fn drain(e: OneOf<($head, $($tail),+)>) -> O { + match e.narrow::<$head, _>() { + Ok(h) => h.into(), + Err(rest) => <($($tail,)+)>::drain(rest), + } + } + } + impl_drain_into!($($tail),+); + }; +} + +// Generates impls for all tuple sizes from 1 up to 7 (restricted by terrors internal impl). +// Each invocation produces one impl then recurses on the tail. +impl_drain_into!(A, B, C, D, E, F, G, H, I); + +// Blanket From impl: body delegates to the recursive drain. +impl> From> for ProtoError { + fn from(e: OneOf) -> Self { + E::drain(e) } } @@ -65,14 +107,14 @@ mod tests { #[test] fn internal_converts_to_proto() { - let e: ProtoError = Internal("boom".into()).into(); + let e: ProtoError = InternalError1("boom".into()).into(); assert!(matches!(e, ProtoError::Internal(msg) if msg == "boom")); } #[test] fn one_of_remainder_converts_to_proto_invalid_signature() { use terrors::OneOf; - let e: OneOf<(InvalidSignature, Internal)> = OneOf::new(InvalidSignature); + let e: OneOf<(InvalidSignature, InternalError1)> = OneOf::new(InvalidSignature); let proto = ProtoError::from(e); assert!(matches!(proto, ProtoError::InvalidSignature)); } @@ -80,7 +122,8 @@ mod tests { #[test] fn one_of_remainder_converts_to_proto_internal() { use terrors::OneOf; - let e: OneOf<(InvalidSignature, Internal)> = OneOf::new(Internal("db fail".into())); + let e: OneOf<(InvalidSignature, InternalError1)> = + OneOf::new(InternalError1("db fail".into())); let proto = ProtoError::from(e); assert!(matches!(proto, ProtoError::Internal(msg) if msg == "db fail")); } diff --git a/server/crates/arbiter-terrors-poc/src/main.rs b/server/crates/arbiter-terrors-poc/src/main.rs index d8e2f7a..f17e180 100644 --- a/server/crates/arbiter-terrors-poc/src/main.rs +++ b/server/crates/arbiter-terrors-poc/src/main.rs @@ -18,9 +18,26 @@ fn run(id: u32, sig: &str) { } } -fn main() { - run(0, "ok"); // NotRegistered - run(1, "bad"); // InvalidSignature - run(99, "ok"); // Internal - run(1, "ok"); // success +fn run_process(id: u32, sig: &str) { + print!("process_request(id={id}, sig={sig:?}) => "); + match auth::process_request(id, sig) { + Ok(s) => println!("Ok({s})"), + Err(e) => println!("Err(ProtoError::{e:?})"), + } +} + +fn main() { + println!("=== authenticate ==="); + run(0, "ok"); // NotRegistered + run(1, "bad"); // InvalidSignature + run(99, "ok"); // InternalError1 + run(98, "ok"); // InternalError2 + run(1, "ok"); // success + + println!("\n=== process_request (Try chain) ==="); + run_process(0, "ok"); // NotRegistered (guard, no I/O) + run_process(97, "ok"); // InternalError2 from load_config + run_process(99, "ok"); // InternalError1 from get_nonce + run_process(1, "bad"); // InvalidSignature from verify_signature + run_process(1, "ok"); // success } From a5a9bc73b0dd2fef610e497531eea539fc81d051 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Mon, 16 Mar 2026 18:19:50 +0100 Subject: [PATCH 05/20] feat(poc): enhance SDK client error handling in user agent module --- .../src/actors/user_agent/mod.rs | 19 ++- .../src/actors/user_agent/session.rs | 109 +++++++----------- server/crates/arbiter-server/src/lib.rs | 33 +++++- .../tests/user_agent/sdk_client.rs | 43 +++---- 4 files changed, 108 insertions(+), 96 deletions(-) diff --git a/server/crates/arbiter-server/src/actors/user_agent/mod.rs b/server/crates/arbiter-server/src/actors/user_agent/mod.rs index 4380b72..6793981 100644 --- a/server/crates/arbiter-server/src/actors/user_agent/mod.rs +++ b/server/crates/arbiter-server/src/actors/user_agent/mod.rs @@ -1,5 +1,7 @@ use arbiter_proto::{ - proto::user_agent::{UserAgentRequest, UserAgentResponse}, + proto::user_agent::{ + SdkClientError as ProtoSdkClientError, UserAgentRequest, UserAgentResponse, + }, transport::Bi, }; use kameo::actor::Spawn as _; @@ -24,12 +26,27 @@ pub enum TransportResponseError { StateTransitionFailed, #[error("Vault is not available")] KeyHolderActorUnreachable, + #[error("SDK client approve failed: {0:?}")] + SdkClientApprove(ProtoSdkClientError), + #[error("SDK client list failed: {0:?}")] + SdkClientList(ProtoSdkClientError), + #[error("SDK client revoke failed: {0:?}")] + SdkClientRevoke(ProtoSdkClientError), #[error(transparent)] Auth(#[from] auth::Error), #[error("Failed registering connection")] ConnectionRegistrationFailed, } +impl TransportResponseError { + pub fn is_terminal(&self) -> bool { + !matches!( + self, + Self::SdkClientApprove(_) | Self::SdkClientList(_) | Self::SdkClientRevoke(_) + ) + } +} + pub type Transport = Box> + Send>; diff --git a/server/crates/arbiter-server/src/actors/user_agent/session.rs b/server/crates/arbiter-server/src/actors/user_agent/session.rs index a19e85b..da53f3a 100644 --- a/server/crates/arbiter-server/src/actors/user_agent/session.rs +++ b/server/crates/arbiter-server/src/actors/user_agent/session.rs @@ -304,11 +304,9 @@ impl UserAgentSession { use sdk_client_approve_response::Result as ApproveResult; if req.pubkey.len() != 32 { - return Ok(response(UserAgentResponsePayload::SdkClientApprove( - SdkClientApproveResponse { - result: Some(ApproveResult::Error(ProtoSdkClientError::Internal.into())), - }, - ))); + return Err(TransportResponseError::SdkClientApprove( + ProtoSdkClientError::Internal, + )); } let now = std::time::SystemTime::now() @@ -320,11 +318,9 @@ impl UserAgentSession { Ok(c) => c, Err(e) => { error!(?e, "Failed to get DB connection for sdk_client_approve"); - return Ok(response(UserAgentResponsePayload::SdkClientApprove( - SdkClientApproveResponse { - result: Some(ApproveResult::Error(ProtoSdkClientError::Internal.into())), - }, - ))); + return Err(TransportResponseError::SdkClientApprove( + ProtoSdkClientError::Internal, + )); } }; @@ -363,33 +359,23 @@ impl UserAgentSession { )), Err(e) => { error!(?e, "Failed to fetch inserted SDK client"); - Ok(response(UserAgentResponsePayload::SdkClientApprove( - SdkClientApproveResponse { - result: Some(ApproveResult::Error( - ProtoSdkClientError::Internal.into(), - )), - }, - ))) + Err(TransportResponseError::SdkClientApprove( + ProtoSdkClientError::Internal, + )) } } } Err(diesel::result::Error::DatabaseError( diesel::result::DatabaseErrorKind::UniqueViolation, _, - )) => Ok(response(UserAgentResponsePayload::SdkClientApprove( - SdkClientApproveResponse { - result: Some(ApproveResult::Error( - ProtoSdkClientError::AlreadyExists.into(), - )), - }, - ))), + )) => Err(TransportResponseError::SdkClientApprove( + ProtoSdkClientError::AlreadyExists, + )), Err(e) => { error!(?e, "Failed to insert SDK client"); - Ok(response(UserAgentResponsePayload::SdkClientApprove( - SdkClientApproveResponse { - result: Some(ApproveResult::Error(ProtoSdkClientError::Internal.into())), - }, - ))) + Err(TransportResponseError::SdkClientApprove( + ProtoSdkClientError::Internal, + )) } } } @@ -399,13 +385,9 @@ impl UserAgentSession { Ok(c) => c, Err(e) => { error!(?e, "Failed to get DB connection for sdk_client_list"); - return Ok(response(UserAgentResponsePayload::SdkClientList( - SdkClientListResponse { - result: Some(sdk_client_list_response::Result::Error( - ProtoSdkClientError::Internal.into(), - )), - }, - ))); + return Err(TransportResponseError::SdkClientList( + ProtoSdkClientError::Internal, + )); } }; @@ -434,13 +416,9 @@ impl UserAgentSession { ))), Err(e) => { error!(?e, "Failed to list SDK clients"); - Ok(response(UserAgentResponsePayload::SdkClientList( - SdkClientListResponse { - result: Some(sdk_client_list_response::Result::Error( - ProtoSdkClientError::Internal.into(), - )), - }, - ))) + Err(TransportResponseError::SdkClientList( + ProtoSdkClientError::Internal, + )) } } } @@ -452,11 +430,9 @@ impl UserAgentSession { Ok(c) => c, Err(e) => { error!(?e, "Failed to get DB connection for sdk_client_revoke"); - return Ok(response(UserAgentResponsePayload::SdkClientRevoke( - SdkClientRevokeResponse { - result: Some(RevokeResult::Error(ProtoSdkClientError::Internal.into())), - }, - ))); + return Err(TransportResponseError::SdkClientRevoke( + ProtoSdkClientError::Internal, + )); } }; @@ -465,11 +441,9 @@ impl UserAgentSession { .execute(&mut conn) .await { - Ok(0) => Ok(response(UserAgentResponsePayload::SdkClientRevoke( - SdkClientRevokeResponse { - result: Some(RevokeResult::Error(ProtoSdkClientError::NotFound.into())), - }, - ))), + Ok(0) => Err(TransportResponseError::SdkClientRevoke( + ProtoSdkClientError::NotFound, + )), Ok(_) => Ok(response(UserAgentResponsePayload::SdkClientRevoke( SdkClientRevokeResponse { result: Some(RevokeResult::Ok(())), @@ -478,20 +452,14 @@ impl UserAgentSession { Err(diesel::result::Error::DatabaseError( diesel::result::DatabaseErrorKind::ForeignKeyViolation, _, - )) => Ok(response(UserAgentResponsePayload::SdkClientRevoke( - SdkClientRevokeResponse { - result: Some(RevokeResult::Error( - ProtoSdkClientError::HasRelatedData.into(), - )), - }, - ))), + )) => Err(TransportResponseError::SdkClientRevoke( + ProtoSdkClientError::HasRelatedData, + )), Err(e) => { error!(?e, "Failed to delete SDK client"); - Ok(response(UserAgentResponsePayload::SdkClientRevoke( - SdkClientRevokeResponse { - result: Some(RevokeResult::Error(ProtoSdkClientError::Internal.into())), - }, - ))) + Err(TransportResponseError::SdkClientRevoke( + ProtoSdkClientError::Internal, + )) } } } @@ -558,8 +526,15 @@ impl Actor for UserAgentSession { } } Err(err) => { - let _ = self.props.transport.send(Err(err)).await; - return Some(kameo::mailbox::Signal::Stop); + let should_stop = err.is_terminal(); + if self.props.transport.send(Err(err)).await.is_err() { + error!(actor = "useragent", reason = "channel closed", "send.failed"); + return Some(kameo::mailbox::Signal::Stop); + } + + if should_stop { + return Some(kameo::mailbox::Signal::Stop); + } } } } diff --git a/server/crates/arbiter-server/src/lib.rs b/server/crates/arbiter-server/src/lib.rs index abb51a5..12eb13e 100644 --- a/server/crates/arbiter-server/src/lib.rs +++ b/server/crates/arbiter-server/src/lib.rs @@ -2,7 +2,12 @@ use arbiter_proto::{ proto::{ client::{ClientRequest, ClientResponse}, - user_agent::{UserAgentRequest, UserAgentResponse}, + user_agent::{ + SdkClientApproveResponse, SdkClientListResponse, SdkClientRevokeResponse, + UserAgentRequest, UserAgentResponse, sdk_client_approve_response, + sdk_client_list_response, sdk_client_revoke_response, + user_agent_response::Payload as UserAgentResponsePayload, + }, }, transport::{IdentityRecvConverter, SendConverter, grpc}, }; @@ -37,6 +42,27 @@ impl SendConverter for UserAgentGrpcSender { fn convert(&self, item: Self::Input) -> Self::Output { match item { Ok(message) => Ok(message), + Err(TransportResponseError::SdkClientApprove(code)) => Ok(UserAgentResponse { + payload: Some(UserAgentResponsePayload::SdkClientApprove( + SdkClientApproveResponse { + result: Some(sdk_client_approve_response::Result::Error(code.into())), + }, + )), + }), + Err(TransportResponseError::SdkClientList(code)) => Ok(UserAgentResponse { + payload: Some(UserAgentResponsePayload::SdkClientList( + SdkClientListResponse { + result: Some(sdk_client_list_response::Result::Error(code.into())), + }, + )), + }), + Err(TransportResponseError::SdkClientRevoke(code)) => Ok(UserAgentResponse { + payload: Some(UserAgentResponsePayload::SdkClientRevoke( + SdkClientRevokeResponse { + result: Some(sdk_client_revoke_response::Result::Error(code.into())), + }, + )), + }), Err(err) => Err(user_agent_error_status(err)), } } @@ -103,6 +129,11 @@ fn user_agent_error_status(value: TransportResponseError) -> Status { TransportResponseError::KeyHolderActorUnreachable => { Status::internal("Vault is not available") } + TransportResponseError::SdkClientApprove(_) + | TransportResponseError::SdkClientList(_) + | TransportResponseError::SdkClientRevoke(_) => { + Status::internal("SDK client operation failed") + } TransportResponseError::Auth(ref err) => auth_error_status(err), TransportResponseError::ConnectionRegistrationFailed => { Status::internal("Failed registering connection") diff --git a/server/crates/arbiter-server/tests/user_agent/sdk_client.rs b/server/crates/arbiter-server/tests/user_agent/sdk_client.rs index 3e2734a..08904dc 100644 --- a/server/crates/arbiter-server/tests/user_agent/sdk_client.rs +++ b/server/crates/arbiter-server/tests/user_agent/sdk_client.rs @@ -5,7 +5,10 @@ use arbiter_proto::proto::user_agent::{ user_agent_response::Payload as UserAgentResponsePayload, }; use arbiter_server::{ - actors::{GlobalActors, user_agent::session::UserAgentSession}, + actors::{ + GlobalActors, + user_agent::{TransportResponseError, session::UserAgentSession}, + }, db, }; @@ -68,22 +71,15 @@ async fn test_sdk_client_approve_duplicate_returns_already_exists() { .await .unwrap(); - let response = session + let err = session .process_transport_inbound(req) .await - .expect("second insert should not panic"); + .expect_err("second insert should return typed TransportResponseError"); - match response.payload.unwrap() { - UserAgentResponsePayload::SdkClientApprove(resp) => match resp.result.unwrap() { - sdk_client_approve_response::Result::Error(code) => { - assert_eq!(code, ProtoSdkClientError::AlreadyExists as i32); - } - sdk_client_approve_response::Result::Client(_) => { - panic!("Expected AlreadyExists error for duplicate pubkey") - } - }, - other => panic!("Expected SdkClientApprove, got {other:?}"), - } + assert_eq!( + err, + TransportResponseError::SdkClientApprove(ProtoSdkClientError::AlreadyExists) + ); } #[tokio::test] @@ -203,26 +199,19 @@ async fn test_sdk_client_revoke_not_found_returns_error() { let db = db::create_test_pool().await; let mut session = make_session(&db).await; - let response = session + let err = session .process_transport_inbound(UserAgentRequest { payload: Some(UserAgentRequestPayload::SdkClientRevoke( SdkClientRevokeRequest { client_id: 9999 }, )), }) .await - .unwrap(); + .expect_err("missing client should return typed TransportResponseError"); - match response.payload.unwrap() { - UserAgentResponsePayload::SdkClientRevoke(resp) => match resp.result.unwrap() { - sdk_client_revoke_response::Result::Error(code) => { - assert_eq!(code, ProtoSdkClientError::NotFound as i32); - } - sdk_client_revoke_response::Result::Ok(_) => { - panic!("Expected NotFound error for missing client_id") - } - }, - other => panic!("Expected SdkClientRevoke, got {other:?}"), - } + assert_eq!( + err, + TransportResponseError::SdkClientRevoke(ProtoSdkClientError::NotFound) + ); } #[tokio::test] From c90af9c19642cdaaef29b04d07f629861fd6f688 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Mon, 16 Mar 2026 18:46:50 +0100 Subject: [PATCH 06/20] fix(server): restore online client approval UX with sdk management --- protobufs/user_agent.proto | 15 ++- .../arbiter-server/src/actors/client/auth.rs | 119 +++++++++++++++++- .../arbiter-server/src/actors/router/mod.rs | 100 ++++++++++++++- .../src/actors/user_agent/session.rs | 63 ++++++++-- server/crates/arbiter-server/src/lib.rs | 2 +- 5 files changed, 280 insertions(+), 19 deletions(-) diff --git a/protobufs/user_agent.proto b/protobufs/user_agent.proto index 2a7e3c0..cb14466 100644 --- a/protobufs/user_agent.proto +++ b/protobufs/user_agent.proto @@ -106,6 +106,16 @@ enum VaultState { VAULT_STATE_ERROR = 4; } +message ClientConnectionRequest { + bytes pubkey = 1; +} + +message ClientConnectionResponse { + bool approved = 1; +} + +message ClientConnectionCancel {} + message UserAgentRequest { oneof payload { AuthChallengeRequest auth_challenge_request = 1; @@ -118,7 +128,7 @@ message UserAgentRequest { arbiter.evm.EvmGrantCreateRequest evm_grant_create = 8; arbiter.evm.EvmGrantDeleteRequest evm_grant_delete = 9; arbiter.evm.EvmGrantListRequest evm_grant_list = 10; - // field 11 reserved: was client_connection_response (online approval removed) + ClientConnectionResponse client_connection_response = 11; SdkClientApproveRequest sdk_client_approve = 12; SdkClientRevokeRequest sdk_client_revoke = 13; google.protobuf.Empty sdk_client_list = 14; @@ -136,7 +146,8 @@ message UserAgentResponse { arbiter.evm.EvmGrantCreateResponse evm_grant_create = 8; arbiter.evm.EvmGrantDeleteResponse evm_grant_delete = 9; arbiter.evm.EvmGrantListResponse evm_grant_list = 10; - // fields 11, 12 reserved: were client_connection_request, client_connection_cancel (online approval removed) + ClientConnectionRequest client_connection_request = 11; + ClientConnectionCancel client_connection_cancel = 12; SdkClientApproveResponse sdk_client_approve = 13; SdkClientRevokeResponse sdk_client_revoke = 14; SdkClientListResponse sdk_client_list = 15; diff --git a/server/crates/arbiter-server/src/actors/client/auth.rs b/server/crates/arbiter-server/src/actors/client/auth.rs index 649b987..55acb4c 100644 --- a/server/crates/arbiter-server/src/actors/client/auth.rs +++ b/server/crates/arbiter-server/src/actors/client/auth.rs @@ -8,13 +8,19 @@ use arbiter_proto::{ }, transport::expect_message, }; -use diesel::{ExpressionMethods as _, OptionalExtension as _, QueryDsl as _, update}; +use diesel::{ + ExpressionMethods as _, OptionalExtension as _, QueryDsl as _, dsl::insert_into, update, +}; use diesel_async::RunQueryDsl as _; use ed25519_dalek::VerifyingKey; +use kameo::error::SendError; use tracing::error; use crate::{ - actors::client::ClientConnection, + actors::{ + client::ClientConnection, + router::{self, RequestClientApproval}, + }, db::{self, schema::program_client}, }; @@ -34,14 +40,24 @@ pub enum Error { DatabaseOperationFailed, #[error("Invalid challenge solution")] InvalidChallengeSolution, - #[error("Client not registered")] - NotRegistered, + #[error("Client approval request failed")] + ApproveError(#[from] ApproveError), #[error("Internal error")] InternalError, #[error("Transport error")] Transport, } +#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)] +pub enum ApproveError { + #[error("Internal error")] + Internal, + #[error("Client connection denied by user agents")] + Denied, + #[error("Upstream error: {0}")] + Upstream(router::ApprovalError), +} + /// Atomically reads and increments the nonce for a known client. /// Returns `None` if the pubkey is not registered. async fn get_nonce( @@ -84,6 +100,85 @@ async fn get_nonce( }) } +async fn approve_new_client( + actors: &crate::actors::GlobalActors, + pubkey: VerifyingKey, +) -> Result<(), Error> { + let result = actors + .router + .ask(RequestClientApproval { + client_pubkey: pubkey, + }) + .await; + + match result { + Ok(true) => Ok(()), + Ok(false) => Err(Error::ApproveError(ApproveError::Denied)), + Err(SendError::HandlerError(e)) => { + error!(error = ?e, "Approval upstream error"); + Err(Error::ApproveError(ApproveError::Upstream(e))) + } + Err(e) => { + error!(error = ?e, "Approval request to router failed"); + Err(Error::ApproveError(ApproveError::Internal)) + } + } +} + +enum InsertClientResult { + Inserted(i32), + AlreadyExists, +} + +async fn insert_client( + db: &db::DatabasePool, + pubkey: &VerifyingKey, +) -> Result { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i32; + + let mut conn = db.get().await.map_err(|e| { + error!(error = ?e, "Database pool error"); + Error::DatabasePoolUnavailable + })?; + + match insert_into(program_client::table) + .values(( + program_client::public_key.eq(pubkey.as_bytes().to_vec()), + program_client::nonce.eq(1), // pre-incremented; challenge uses 0 + program_client::created_at.eq(now), + program_client::updated_at.eq(now), + )) + .execute(&mut conn) + .await + { + Ok(_) => {} + Err(diesel::result::Error::DatabaseError( + diesel::result::DatabaseErrorKind::UniqueViolation, + _, + )) => return Ok(InsertClientResult::AlreadyExists), + Err(e) => { + error!(error = ?e, "Failed to insert new client"); + return Err(Error::DatabaseOperationFailed); + } + } + + let client_id = program_client::table + .filter(program_client::public_key.eq(pubkey.as_bytes().to_vec())) + .order(program_client::id.desc()) + .select(program_client::id) + .first::(&mut conn) + .await + .map_err(|e| { + error!(error = ?e, "Failed to load inserted client id"); + Error::DatabaseOperationFailed + })?; + + Ok(InsertClientResult::Inserted(client_id)) +} + async fn challenge_client( props: &mut ClientConnection, pubkey: VerifyingKey, @@ -134,7 +229,10 @@ async fn challenge_client( fn connect_error_code(err: &Error) -> ConnectErrorCode { match err { - Error::NotRegistered => ConnectErrorCode::ApprovalDenied, + Error::ApproveError(ApproveError::Denied) => ConnectErrorCode::ApprovalDenied, + Error::ApproveError(ApproveError::Upstream( + router::ApprovalError::NoUserAgentsConnected, + )) => ConnectErrorCode::NoUserAgentsOnline, _ => ConnectErrorCode::Unknown, } } @@ -156,7 +254,16 @@ async fn authenticate(props: &mut ClientConnection) -> Result<(VerifyingKey, i32 let (client_id, nonce) = match get_nonce(&props.db, &pubkey).await? { Some((client_id, nonce)) => (client_id, nonce), - None => return Err(Error::NotRegistered), + None => { + approve_new_client(&props.actors, pubkey).await?; + match insert_client(&props.db, &pubkey).await? { + InsertClientResult::Inserted(client_id) => (client_id, 0), + InsertClientResult::AlreadyExists => match get_nonce(&props.db, &pubkey).await? { + Some((client_id, nonce)) => (client_id, nonce), + None => return Err(Error::InternalError), + }, + } + } }; challenge_client(props, pubkey, nonce).await?; diff --git a/server/crates/arbiter-server/src/actors/router/mod.rs b/server/crates/arbiter-server/src/actors/router/mod.rs index 8d06152..b7303de 100644 --- a/server/crates/arbiter-server/src/actors/router/mod.rs +++ b/server/crates/arbiter-server/src/actors/router/mod.rs @@ -1,14 +1,20 @@ use std::{collections::HashMap, ops::ControlFlow}; +use ed25519_dalek::VerifyingKey; use kameo::{ Actor, actor::{ActorId, ActorRef}, messages, prelude::{ActorStopReason, Context, WeakActorRef}, + reply::DelegatedReply, }; -use tracing::info; +use tokio::{sync::watch, task::JoinSet}; +use tracing::{info, warn}; -use crate::actors::{client::session::ClientSession, user_agent::session::UserAgentSession}; +use crate::actors::{ + client::session::ClientSession, + user_agent::session::{RequestNewClientApproval, UserAgentSession}, +}; #[derive(Default)] pub struct MessageRouter { @@ -50,6 +56,72 @@ impl Actor for MessageRouter { } } +#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq, Hash)] +pub enum ApprovalError { + #[error("No user agents connected")] + NoUserAgentsConnected, +} + +async fn request_client_approval( + user_agents: &[WeakActorRef], + client_pubkey: VerifyingKey, +) -> Result { + if user_agents.is_empty() { + return Err(ApprovalError::NoUserAgentsConnected); + } + + let mut pool = JoinSet::new(); + let (cancel_tx, cancel_rx) = watch::channel(()); + + for weak_ref in user_agents { + match weak_ref.upgrade() { + Some(agent) => { + let cancel_rx = cancel_rx.clone(); + pool.spawn(async move { + agent + .ask(RequestNewClientApproval { + client_pubkey, + cancel_flag: cancel_rx.clone(), + }) + .await + }); + } + None => { + warn!( + id = weak_ref.id().to_string(), + actor = "MessageRouter", + event = "useragent.disconnected_before_approval" + ); + } + } + } + + while let Some(result) = pool.join_next().await { + match result { + Ok(Ok(approved)) => { + let _ = cancel_tx.send(()); + return Ok(approved); + } + Ok(Err(err)) => { + warn!( + ?err, + actor = "MessageRouter", + event = "useragent.approval_error" + ); + } + Err(err) => { + warn!( + ?err, + actor = "MessageRouter", + event = "useragent.approval_task_failed" + ); + } + } + } + + Err(ApprovalError::NoUserAgentsConnected) +} + #[messages] impl MessageRouter { #[message(ctx)] @@ -73,4 +145,28 @@ impl MessageRouter { ctx.actor_ref().link(&actor).await; self.clients.insert(actor.id(), actor); } + + #[message(ctx)] + pub async fn request_client_approval( + &mut self, + client_pubkey: VerifyingKey, + ctx: &mut Context>>, + ) -> DelegatedReply> { + let (reply, Some(reply_sender)) = ctx.reply_sender() else { + panic!("Exptected `request_client_approval` to have callback channel"); + }; + + let weak_refs = self + .user_agents + .values() + .map(|agent| agent.downgrade()) + .collect::>(); + + tokio::task::spawn(async move { + let result = request_client_approval(&weak_refs, client_pubkey).await; + reply_sender.send(result); + }); + + reply + } } diff --git a/server/crates/arbiter-server/src/actors/user_agent/session.rs b/server/crates/arbiter-server/src/actors/user_agent/session.rs index da53f3a..7edd38e 100644 --- a/server/crates/arbiter-server/src/actors/user_agent/session.rs +++ b/server/crates/arbiter-server/src/actors/user_agent/session.rs @@ -3,21 +3,22 @@ use std::{ops::DerefMut, sync::Mutex}; use arbiter_proto::proto::{ evm as evm_proto, user_agent::{ - SdkClientApproveRequest, SdkClientApproveResponse, SdkClientEntry, - SdkClientError as ProtoSdkClientError, SdkClientList, SdkClientListResponse, - SdkClientRevokeRequest, SdkClientRevokeResponse, UnsealEncryptedKey, UnsealResult, - UnsealStart, UnsealStartResponse, UserAgentRequest, UserAgentResponse, - sdk_client_approve_response, sdk_client_list_response, sdk_client_revoke_response, - user_agent_request::Payload as UserAgentRequestPayload, + ClientConnectionCancel, ClientConnectionRequest, SdkClientApproveRequest, + SdkClientApproveResponse, SdkClientEntry, SdkClientError as ProtoSdkClientError, + SdkClientList, SdkClientListResponse, SdkClientRevokeRequest, SdkClientRevokeResponse, + UnsealEncryptedKey, UnsealResult, UnsealStart, UnsealStartResponse, UserAgentRequest, + UserAgentResponse, sdk_client_approve_response, sdk_client_list_response, + sdk_client_revoke_response, user_agent_request::Payload as UserAgentRequestPayload, user_agent_response::Payload as UserAgentResponsePayload, }, }; use chacha20poly1305::{AeadInPlace, XChaCha20Poly1305, XNonce, aead::KeyInit}; use diesel::{ExpressionMethods as _, QueryDsl as _, dsl::insert_into}; use diesel_async::RunQueryDsl as _; -use kameo::{Actor, error::SendError, prelude::Context}; +use ed25519_dalek::VerifyingKey; +use kameo::{Actor, error::SendError, messages, prelude::Context}; use memsafe::MemSafe; -use tokio::select; +use tokio::{select, sync::watch}; use tracing::{error, info}; use x25519_dalek::{EphemeralSecret, PublicKey}; @@ -115,6 +116,52 @@ impl UserAgentSession { } } +#[messages] +impl UserAgentSession { + // TODO: Think about refactoring it to state-machine based flow, as we already have one + #[message(ctx)] + pub async fn request_new_client_approval( + &mut self, + client_pubkey: VerifyingKey, + mut cancel_flag: watch::Receiver<()>, + ctx: &mut Context>, + ) -> Result { + self.send_msg( + UserAgentResponsePayload::ClientConnectionRequest(ClientConnectionRequest { + pubkey: client_pubkey.as_bytes().to_vec(), + }), + ctx, + ) + .await?; + + let extractor = |msg| { + if let UserAgentRequestPayload::ClientConnectionResponse(client_connection_response) = + msg + { + Some(client_connection_response) + } else { + None + } + }; + + tokio::select! { + _ = cancel_flag.changed() => { + info!(actor = "useragent", "client connection approval cancelled"); + self.send_msg( + UserAgentResponsePayload::ClientConnectionCancel(ClientConnectionCancel {}), + ctx, + ).await?; + Ok(false) + } + result = self.expect_msg(extractor, ctx) => { + let result = result?; + info!(actor = "useragent", "received client connection approval result: approved={}", result.approved); + Ok(result.approved) + } + } + } +} + impl UserAgentSession { pub async fn process_transport_inbound(&mut self, req: UserAgentRequest) -> Output { let msg = req.payload.ok_or_else(|| { diff --git a/server/crates/arbiter-server/src/lib.rs b/server/crates/arbiter-server/src/lib.rs index 12eb13e..ed2a2e5 100644 --- a/server/crates/arbiter-server/src/lib.rs +++ b/server/crates/arbiter-server/src/lib.rs @@ -105,7 +105,7 @@ fn client_auth_error_status(value: &client::auth::Error) -> Status { Status::invalid_argument("Failed to convert pubkey to VerifyingKey") } Error::InvalidChallengeSolution => Status::unauthenticated(value.to_string()), - Error::NotRegistered => Status::permission_denied(value.to_string()), + Error::ApproveError(_) => Status::permission_denied(value.to_string()), Error::Transport => Status::internal("Transport error"), Error::DatabasePoolUnavailable => Status::internal("Database pool error"), Error::DatabaseOperationFailed => Status::internal("Database error"), From 6f03ce4d1dd975a4b4eeeb82adc2b96facf8d858 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Tue, 17 Mar 2026 19:42:35 +0100 Subject: [PATCH 07/20] chore: remove invalidly committed PoC crate --- server/crates/arbiter-terrors-poc/Cargo.toml | 7 - server/crates/arbiter-terrors-poc/src/auth.rs | 139 ------------------ server/crates/arbiter-terrors-poc/src/db.rs | 38 ----- .../crates/arbiter-terrors-poc/src/errors.rs | 130 ---------------- server/crates/arbiter-terrors-poc/src/main.rs | 43 ------ 5 files changed, 357 deletions(-) delete mode 100644 server/crates/arbiter-terrors-poc/Cargo.toml delete mode 100644 server/crates/arbiter-terrors-poc/src/auth.rs delete mode 100644 server/crates/arbiter-terrors-poc/src/db.rs delete mode 100644 server/crates/arbiter-terrors-poc/src/errors.rs delete mode 100644 server/crates/arbiter-terrors-poc/src/main.rs diff --git a/server/crates/arbiter-terrors-poc/Cargo.toml b/server/crates/arbiter-terrors-poc/Cargo.toml deleted file mode 100644 index 127adb1..0000000 --- a/server/crates/arbiter-terrors-poc/Cargo.toml +++ /dev/null @@ -1,7 +0,0 @@ -[package] -name = "arbiter-terrors-poc" -version = "0.1.0" -edition = "2024" - -[dependencies] -terrors = "0.3" diff --git a/server/crates/arbiter-terrors-poc/src/auth.rs b/server/crates/arbiter-terrors-poc/src/auth.rs deleted file mode 100644 index b9f6148..0000000 --- a/server/crates/arbiter-terrors-poc/src/auth.rs +++ /dev/null @@ -1,139 +0,0 @@ -use crate::errors::{InternalError1, InternalError2, InvalidSignature, NotRegistered}; -use terrors::OneOf; - -use crate::errors::ProtoError; - -// Each sub-call's error type already implements DrainInto, so we convert -// directly to ProtoError without broaden — no turbofish needed anywhere. -// -// Call chain: -// load_config() → OneOf<(InternalError2,)> → ProtoError::from -// get_nonce() → OneOf<(InternalError1, InternalError2)> → ProtoError::from -// verify_sig() → OneOf<(InvalidSignature,)> → ProtoError::from -pub fn process_request(id: u32, sig: &str) -> Result { - if id == 0 { - return Err(ProtoError::NotRegistered); - } - - let config = load_config(id).map_err(ProtoError::from)?; - let nonce = crate::db::get_nonce(id).map_err(ProtoError::from)?; - verify_signature(nonce, sig).map_err(ProtoError::from)?; - - Ok(format!("config={config} nonce={nonce} sig={sig}")) -} - -// Simulates loading a config value. -// id=97 triggers InternalError2 ("config read failed"). -fn load_config(id: u32) -> Result> { - if id == 97 { - return Err(OneOf::new(InternalError2("config read failed".to_owned()))); - } - Ok(format!("cfg-{id}")) -} - -pub fn verify_signature(_nonce: u32, sig: &str) -> Result<(), OneOf<(InvalidSignature,)>> { - if sig != "ok" { - return Err(OneOf::new(InvalidSignature)); - } - Ok(()) -} - -type AuthError = OneOf<( - NotRegistered, - InvalidSignature, - InternalError1, - InternalError2, -)>; - -pub fn authenticate(id: u32, sig: &str) -> Result { - if id == 0 { - return Err(OneOf::new(NotRegistered)); - } - - // Return type AuthError lets the compiler infer the broaden target. - let nonce = crate::db::get_nonce(id).map_err(OneOf::broaden)?; - verify_signature(nonce, sig).map_err(OneOf::broaden)?; - - Ok(nonce) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn verify_signature_ok() { - assert!(verify_signature(42, "ok").is_ok()); - } - - #[test] - fn verify_signature_bad() { - let err = verify_signature(42, "bad").unwrap_err(); - assert!(err.narrow::().is_ok()); - } - - #[test] - fn authenticate_success() { - assert_eq!(authenticate(1, "ok").unwrap(), 42); - } - - #[test] - fn authenticate_not_registered() { - let err = authenticate(0, "ok").unwrap_err(); - assert!(err.narrow::().is_ok()); - } - - #[test] - fn authenticate_invalid_signature() { - let err = authenticate(1, "bad").unwrap_err(); - assert!(err.narrow::().is_ok()); - } - - #[test] - fn authenticate_internal_error1() { - let err = authenticate(99, "ok").unwrap_err(); - assert!(err.narrow::().is_ok()); - } - - #[test] - fn authenticate_internal_error2() { - let err = authenticate(98, "ok").unwrap_err(); - assert!(err.narrow::().is_ok()); - } - - #[test] - fn process_request_success() { - let result = process_request(1, "ok").unwrap(); - assert!(result.contains("nonce=42")); - } - - #[test] - fn process_request_not_registered() { - let err = process_request(0, "ok").unwrap_err(); - assert!(matches!(err, crate::errors::ProtoError::NotRegistered)); - } - - #[test] - fn process_request_invalid_signature() { - let err = process_request(1, "bad").unwrap_err(); - assert!(matches!(err, crate::errors::ProtoError::InvalidSignature)); - } - - #[test] - fn process_request_internal_from_config() { - // id=97 → load_config returns InternalError2 - let err = process_request(97, "ok").unwrap_err(); - assert!( - matches!(err, crate::errors::ProtoError::Internal(ref msg) if msg == "config read failed") - ); - } - - #[test] - fn process_request_internal_from_db() { - // id=99 → get_nonce returns InternalError1 - let err = process_request(99, "ok").unwrap_err(); - assert!( - matches!(err, crate::errors::ProtoError::Internal(ref msg) if msg == "db pool unavailable") - ); - } -} diff --git a/server/crates/arbiter-terrors-poc/src/db.rs b/server/crates/arbiter-terrors-poc/src/db.rs deleted file mode 100644 index c2c4c5a..0000000 --- a/server/crates/arbiter-terrors-poc/src/db.rs +++ /dev/null @@ -1,38 +0,0 @@ -use crate::errors::{InternalError1, InternalError2}; -use terrors::OneOf; - -// Simulates fetching a nonce from a database. -// id=99 → InternalError1 (pool unavailable) -// id=98 → InternalError2 (query timeout) -pub fn get_nonce(id: u32) -> Result> { - match id { - 99 => Err(OneOf::new(InternalError1("db pool unavailable".to_owned()))), - 98 => Err(OneOf::new(InternalError2("query timeout".to_owned()))), - _ => Ok(42), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn get_nonce_returns_nonce_for_valid_id() { - assert_eq!(get_nonce(1).unwrap(), 42); - } - - #[test] - fn get_nonce_returns_internal_error1_for_sentinel() { - let err = get_nonce(99).unwrap_err(); - let internal = err.narrow::().unwrap(); - assert_eq!(internal.0, "db pool unavailable"); - } - - #[test] - fn get_nonce_returns_internal_error2_for_sentinel() { - let err = get_nonce(98).unwrap_err(); - let e = err.narrow::().unwrap_err(); - let internal = e.take::(); - assert_eq!(internal.0, "query timeout"); - } -} diff --git a/server/crates/arbiter-terrors-poc/src/errors.rs b/server/crates/arbiter-terrors-poc/src/errors.rs deleted file mode 100644 index cb436e1..0000000 --- a/server/crates/arbiter-terrors-poc/src/errors.rs +++ /dev/null @@ -1,130 +0,0 @@ -use terrors::OneOf; - -// Wire boundary type — what would go into a proto response -#[derive(Debug)] -pub enum ProtoError { - NotRegistered, - InvalidSignature, - Internal(String), // Or Box, who cares? -} - -// Internal terrors types -#[derive(Debug)] -pub struct NotRegistered; -#[derive(Debug)] -pub struct InvalidSignature; -#[derive(Debug)] -pub struct InternalError1(pub String); -#[derive(Debug)] -pub struct InternalError2(pub String); - -// Errors can be scattered across the codebase as long as they implement Into -impl From for ProtoError { - fn from(_: NotRegistered) -> Self { - ProtoError::NotRegistered - } -} - -impl From for ProtoError { - fn from(_: InvalidSignature) -> Self { - ProtoError::InvalidSignature - } -} - -impl From for ProtoError { - fn from(e: InternalError1) -> Self { - ProtoError::Internal(e.0) - } -} -impl From for ProtoError { - fn from(e: InternalError2) -> Self { - ProtoError::Internal(e.0) - } -} - -/// Private helper trait for converting from OneOf where each T can be converted -/// into the target type `O` by recursively narrowing until a match is found. -/// -/// IDK why this isn't already in terrors. -trait DrainInto: terrors::TypeSet + Sized { - fn drain(e: OneOf) -> O; -} - -macro_rules! impl_drain_into { - ($head:ident) => { - impl<$head, O> DrainInto for ($head,) - where - $head: Into + 'static, - { - fn drain(e: OneOf<($head,)>) -> O { - e.take().into() - } - } - }; - ($head:ident, $($tail:ident),+) => { - impl<$head, $($tail),+, O> DrainInto for ($head, $($tail),+) - where - $head: Into + 'static, - ($($tail,)+): DrainInto, - { - fn drain(e: OneOf<($head, $($tail),+)>) -> O { - match e.narrow::<$head, _>() { - Ok(h) => h.into(), - Err(rest) => <($($tail,)+)>::drain(rest), - } - } - } - impl_drain_into!($($tail),+); - }; -} - -// Generates impls for all tuple sizes from 1 up to 7 (restricted by terrors internal impl). -// Each invocation produces one impl then recurses on the tail. -impl_drain_into!(A, B, C, D, E, F, G, H, I); - -// Blanket From impl: body delegates to the recursive drain. -impl> From> for ProtoError { - fn from(e: OneOf) -> Self { - E::drain(e) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn not_registered_converts_to_proto() { - let e: ProtoError = NotRegistered.into(); - assert!(matches!(e, ProtoError::NotRegistered)); - } - - #[test] - fn invalid_signature_converts_to_proto() { - let e: ProtoError = InvalidSignature.into(); - assert!(matches!(e, ProtoError::InvalidSignature)); - } - - #[test] - fn internal_converts_to_proto() { - let e: ProtoError = InternalError1("boom".into()).into(); - assert!(matches!(e, ProtoError::Internal(msg) if msg == "boom")); - } - - #[test] - fn one_of_remainder_converts_to_proto_invalid_signature() { - use terrors::OneOf; - let e: OneOf<(InvalidSignature, InternalError1)> = OneOf::new(InvalidSignature); - let proto = ProtoError::from(e); - assert!(matches!(proto, ProtoError::InvalidSignature)); - } - - #[test] - fn one_of_remainder_converts_to_proto_internal() { - use terrors::OneOf; - let e: OneOf<(InvalidSignature, InternalError1)> = - OneOf::new(InternalError1("db fail".into())); - let proto = ProtoError::from(e); - assert!(matches!(proto, ProtoError::Internal(msg) if msg == "db fail")); - } -} diff --git a/server/crates/arbiter-terrors-poc/src/main.rs b/server/crates/arbiter-terrors-poc/src/main.rs deleted file mode 100644 index f17e180..0000000 --- a/server/crates/arbiter-terrors-poc/src/main.rs +++ /dev/null @@ -1,43 +0,0 @@ -mod auth; -mod db; -mod errors; - -use errors::ProtoError; - -fn run(id: u32, sig: &str) { - print!("authenticate(id={id}, sig={sig:?}) => "); - match auth::authenticate(id, sig) { - Ok(nonce) => println!("Ok(nonce={nonce})"), - Err(e) => match e.narrow::() { - Ok(_) => println!("Err(NotRegistered) — handled locally"), - Err(remaining) => { - let proto = ProtoError::from(remaining); - println!("Err(ProtoError::{proto:?}) — forwarded to wire"); - } - }, - } -} - -fn run_process(id: u32, sig: &str) { - print!("process_request(id={id}, sig={sig:?}) => "); - match auth::process_request(id, sig) { - Ok(s) => println!("Ok({s})"), - Err(e) => println!("Err(ProtoError::{e:?})"), - } -} - -fn main() { - println!("=== authenticate ==="); - run(0, "ok"); // NotRegistered - run(1, "bad"); // InvalidSignature - run(99, "ok"); // InternalError1 - run(98, "ok"); // InternalError2 - run(1, "ok"); // success - - println!("\n=== process_request (Try chain) ==="); - run_process(0, "ok"); // NotRegistered (guard, no I/O) - run_process(97, "ok"); // InternalError2 from load_config - run_process(99, "ok"); // InternalError1 from get_nonce - run_process(1, "bad"); // InvalidSignature from verify_signature - run_process(1, "ok"); // success -} From 77c3babec750a6bd844a0ae855ff3eef2ef08afb Mon Sep 17 00:00:00 2001 From: CleverWild Date: Tue, 17 Mar 2026 19:44:58 +0100 Subject: [PATCH 08/20] feat: compat migrations --- .../migrations/2026-02-14-171124-0000_init/up.sql | 2 ++ .../down.sql | 1 - .../up.sql | 2 -- 3 files changed, 2 insertions(+), 3 deletions(-) delete mode 100644 server/crates/arbiter-server/migrations/2026-03-15-103112-0000_add_program_client_pubkey_unique/down.sql delete mode 100644 server/crates/arbiter-server/migrations/2026-03-15-103112-0000_add_program_client_pubkey_unique/up.sql diff --git a/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql b/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql index 3419ef9..e69c3d9 100644 --- a/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql +++ b/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql @@ -157,3 +157,5 @@ create table if not exists evm_ether_transfer_grant_target ( create unique index if not exists uniq_ether_transfer_target on evm_ether_transfer_grant_target(grant_id, address); +CREATE UNIQUE INDEX program_client_public_key_unique + ON program_client (public_key); diff --git a/server/crates/arbiter-server/migrations/2026-03-15-103112-0000_add_program_client_pubkey_unique/down.sql b/server/crates/arbiter-server/migrations/2026-03-15-103112-0000_add_program_client_pubkey_unique/down.sql deleted file mode 100644 index aeda4ed..0000000 --- a/server/crates/arbiter-server/migrations/2026-03-15-103112-0000_add_program_client_pubkey_unique/down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP INDEX IF EXISTS program_client_public_key_unique; diff --git a/server/crates/arbiter-server/migrations/2026-03-15-103112-0000_add_program_client_pubkey_unique/up.sql b/server/crates/arbiter-server/migrations/2026-03-15-103112-0000_add_program_client_pubkey_unique/up.sql deleted file mode 100644 index 5d1d4a2..0000000 --- a/server/crates/arbiter-server/migrations/2026-03-15-103112-0000_add_program_client_pubkey_unique/up.sql +++ /dev/null @@ -1,2 +0,0 @@ -CREATE UNIQUE INDEX program_client_public_key_unique - ON program_client (public_key); From 434738bae5f741e5d5e81b3da4aaa162c2bc2cf9 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Thu, 19 Mar 2026 17:52:11 +0100 Subject: [PATCH 09/20] fix: return very important comment --- server/crates/arbiter-server/src/actors/router/mod.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/crates/arbiter-server/src/actors/router/mod.rs b/server/crates/arbiter-server/src/actors/router/mod.rs index b7303de..ede72c4 100644 --- a/server/crates/arbiter-server/src/actors/router/mod.rs +++ b/server/crates/arbiter-server/src/actors/router/mod.rs @@ -99,6 +99,7 @@ async fn request_client_approval( while let Some(result) = pool.join_next().await { match result { Ok(Ok(approved)) => { + // cancel other pending requests let _ = cancel_tx.send(()); return Ok(approved); } @@ -153,7 +154,7 @@ impl MessageRouter { ctx: &mut Context>>, ) -> DelegatedReply> { let (reply, Some(reply_sender)) = ctx.reply_sender() else { - panic!("Exptected `request_client_approval` to have callback channel"); + panic!("Expected `request_client_approval` to have callback channel"); }; let weak_refs = self From f56668d9f60593cfa99d43aa7ae9cd62e2dad214 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Thu, 19 Mar 2026 17:54:31 +0100 Subject: [PATCH 10/20] chore: make const for buffer size --- server/crates/arbiter-client/src/lib.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/server/crates/arbiter-client/src/lib.rs b/server/crates/arbiter-client/src/lib.rs index 322a9bb..753c21a 100644 --- a/server/crates/arbiter-client/src/lib.rs +++ b/server/crates/arbiter-client/src/lib.rs @@ -25,6 +25,8 @@ use tokio::sync::{Mutex, mpsc}; use tokio_stream::wrappers::ReceiverStream; use tonic::transport::ClientTlsConfig; +const BUFFER_LENGTH: usize = 16; + #[derive(Debug, thiserror::Error)] pub enum ConnectError { #[error("Could not establish connection")] @@ -112,7 +114,7 @@ impl ArbiterSigner { .await?; let mut client = ArbiterServiceClient::new(channel); - let (tx, rx) = mpsc::channel(16); + let (tx, rx) = mpsc::channel(BUFFER_LENGTH); let response_stream = client.client(ReceiverStream::new(rx)).await?.into_inner(); let mut transport = ClientTransport { From e89983de3a521dd4111617b95df6b83fd3eb50ea Mon Sep 17 00:00:00 2001 From: CleverWild Date: Thu, 19 Mar 2026 18:00:10 +0100 Subject: [PATCH 11/20] refactor(proto): align remaining ClientConnection protobuf pairs with SdkClient* naming --- protobufs/user_agent.proto | 12 ++++++------ .../arbiter-server/src/actors/user_agent/session.rs | 13 +++++++------ 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/protobufs/user_agent.proto b/protobufs/user_agent.proto index cb14466..9ecc01c 100644 --- a/protobufs/user_agent.proto +++ b/protobufs/user_agent.proto @@ -106,15 +106,15 @@ enum VaultState { VAULT_STATE_ERROR = 4; } -message ClientConnectionRequest { +message SdkClientConnectionRequest { bytes pubkey = 1; } -message ClientConnectionResponse { +message SdkClientConnectionResponse { bool approved = 1; } -message ClientConnectionCancel {} +message SdkClientConnectionCancel {} message UserAgentRequest { oneof payload { @@ -128,7 +128,7 @@ message UserAgentRequest { arbiter.evm.EvmGrantCreateRequest evm_grant_create = 8; arbiter.evm.EvmGrantDeleteRequest evm_grant_delete = 9; arbiter.evm.EvmGrantListRequest evm_grant_list = 10; - ClientConnectionResponse client_connection_response = 11; + SdkClientConnectionResponse sdk_client_connection_response = 11; SdkClientApproveRequest sdk_client_approve = 12; SdkClientRevokeRequest sdk_client_revoke = 13; google.protobuf.Empty sdk_client_list = 14; @@ -146,8 +146,8 @@ message UserAgentResponse { arbiter.evm.EvmGrantCreateResponse evm_grant_create = 8; arbiter.evm.EvmGrantDeleteResponse evm_grant_delete = 9; arbiter.evm.EvmGrantListResponse evm_grant_list = 10; - ClientConnectionRequest client_connection_request = 11; - ClientConnectionCancel client_connection_cancel = 12; + SdkClientConnectionRequest sdk_client_connection_request = 11; + SdkClientConnectionCancel sdk_client_connection_cancel = 12; SdkClientApproveResponse sdk_client_approve = 13; SdkClientRevokeResponse sdk_client_revoke = 14; SdkClientListResponse sdk_client_list = 15; diff --git a/server/crates/arbiter-server/src/actors/user_agent/session.rs b/server/crates/arbiter-server/src/actors/user_agent/session.rs index 7edd38e..9316f80 100644 --- a/server/crates/arbiter-server/src/actors/user_agent/session.rs +++ b/server/crates/arbiter-server/src/actors/user_agent/session.rs @@ -3,8 +3,8 @@ use std::{ops::DerefMut, sync::Mutex}; use arbiter_proto::proto::{ evm as evm_proto, user_agent::{ - ClientConnectionCancel, ClientConnectionRequest, SdkClientApproveRequest, - SdkClientApproveResponse, SdkClientEntry, SdkClientError as ProtoSdkClientError, + SdkClientApproveRequest, SdkClientApproveResponse, SdkClientConnectionCancel, + SdkClientConnectionRequest, SdkClientEntry, SdkClientError as ProtoSdkClientError, SdkClientList, SdkClientListResponse, SdkClientRevokeRequest, SdkClientRevokeResponse, UnsealEncryptedKey, UnsealResult, UnsealStart, UnsealStartResponse, UserAgentRequest, UserAgentResponse, sdk_client_approve_response, sdk_client_list_response, @@ -127,7 +127,7 @@ impl UserAgentSession { ctx: &mut Context>, ) -> Result { self.send_msg( - UserAgentResponsePayload::ClientConnectionRequest(ClientConnectionRequest { + UserAgentResponsePayload::SdkClientConnectionRequest(SdkClientConnectionRequest { pubkey: client_pubkey.as_bytes().to_vec(), }), ctx, @@ -135,8 +135,9 @@ impl UserAgentSession { .await?; let extractor = |msg| { - if let UserAgentRequestPayload::ClientConnectionResponse(client_connection_response) = - msg + if let UserAgentRequestPayload::SdkClientConnectionResponse( + client_connection_response, + ) = msg { Some(client_connection_response) } else { @@ -148,7 +149,7 @@ impl UserAgentSession { _ = cancel_flag.changed() => { info!(actor = "useragent", "client connection approval cancelled"); self.send_msg( - UserAgentResponsePayload::ClientConnectionCancel(ClientConnectionCancel {}), + UserAgentResponsePayload::SdkClientConnectionCancel(SdkClientConnectionCancel {}), ctx, ).await?; Ok(false) From c87456ae2f42dc81e87003265e6748b2b08c052b Mon Sep 17 00:00:00 2001 From: CleverWild Date: Thu, 19 Mar 2026 18:10:43 +0100 Subject: [PATCH 12/20] feat(client): add file-backed signing key storage with transparent first-run key creation --- server/crates/arbiter-client/Cargo.toml | 1 + server/crates/arbiter-client/src/lib.rs | 157 +++++++++++++++++++++++- 2 files changed, 155 insertions(+), 3 deletions(-) diff --git a/server/crates/arbiter-client/Cargo.toml b/server/crates/arbiter-client/Cargo.toml index 597a26e..e035fd2 100644 --- a/server/crates/arbiter-client/Cargo.toml +++ b/server/crates/arbiter-client/Cargo.toml @@ -20,3 +20,4 @@ thiserror.workspace = true http = "1.4.0" rustls-webpki = { version = "0.103.9", features = ["aws-lc-rs"] } async-trait.workspace = true +rand.workspace = true diff --git a/server/crates/arbiter-client/src/lib.rs b/server/crates/arbiter-client/src/lib.rs index 753c21a..936875c 100644 --- a/server/crates/arbiter-client/src/lib.rs +++ b/server/crates/arbiter-client/src/lib.rs @@ -5,7 +5,7 @@ use alloy::{ signers::{Error, Result, Signer}, }; use arbiter_proto::{ - format_challenge, + format_challenge, home_path, proto::{ arbiter_service_client::ArbiterServiceClient, client::{ @@ -21,6 +21,7 @@ use arbiter_proto::{ }; use async_trait::async_trait; use ed25519_dalek::Signer as _; +use std::path::{Path, PathBuf}; use tokio::sync::{Mutex, mpsc}; use tokio_stream::wrappers::ReceiverStream; use tonic::transport::ClientTlsConfig; @@ -52,6 +53,83 @@ pub enum ConnectError { #[error("Unexpected auth response payload")] UnexpectedAuthResponse, + + #[error("Signing key storage error")] + Storage(#[from] StorageError), +} + +#[derive(Debug, thiserror::Error)] +pub enum StorageError { + #[error("I/O error")] + Io(#[from] std::io::Error), + + #[error("Invalid signing key length in storage: expected {expected} bytes, got {actual} bytes")] + InvalidKeyLength { expected: usize, actual: usize }, +} + +pub trait SigningKeyStorage { + fn load_or_create(&self) -> std::result::Result; +} + +#[derive(Debug, Clone)] +pub struct FileSigningKeyStorage { + path: PathBuf, +} + +impl FileSigningKeyStorage { + pub const DEFAULT_FILE_NAME: &str = "sdk_client_ed25519.key"; + + pub fn new(path: impl Into) -> Self { + Self { path: path.into() } + } + + pub fn from_default_location() -> std::result::Result { + Ok(Self::new(home_path()?.join(Self::DEFAULT_FILE_NAME))) + } + + fn read_key(path: &Path) -> std::result::Result { + let bytes = std::fs::read(path)?; + let raw: [u8; 32] = + bytes + .try_into() + .map_err(|v: Vec| StorageError::InvalidKeyLength { + expected: 32, + actual: v.len(), + })?; + Ok(ed25519_dalek::SigningKey::from_bytes(&raw)) + } +} + +impl SigningKeyStorage for FileSigningKeyStorage { + fn load_or_create(&self) -> std::result::Result { + if let Some(parent) = self.path.parent() { + std::fs::create_dir_all(parent)?; + } + + if self.path.exists() { + return Self::read_key(&self.path); + } + + let key = ed25519_dalek::SigningKey::generate(&mut rand::rng()); + let raw_key = key.to_bytes(); + + // Use create_new to prevent accidental overwrite if another process creates the key first. + match std::fs::OpenOptions::new() + .create_new(true) + .write(true) + .open(&self.path) + { + Ok(mut file) => { + use std::io::Write as _; + file.write_all(&raw_key)?; + Ok(key) + } + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => { + Self::read_key(&self.path) + } + Err(err) => Err(StorageError::Io(err)), + } + } } #[derive(Debug, thiserror::Error)] @@ -99,6 +177,23 @@ pub struct ArbiterSigner { impl ArbiterSigner { pub async fn connect_grpc( + url: ArbiterUrl, + address: Address, + ) -> std::result::Result { + let storage = FileSigningKeyStorage::from_default_location()?; + Self::connect_grpc_with_storage(url, address, &storage).await + } + + pub async fn connect_grpc_with_storage( + url: ArbiterUrl, + address: Address, + storage: &S, + ) -> std::result::Result { + let key = storage.load_or_create()?; + Self::connect_grpc_with_key(url, key, address).await + } + + pub async fn connect_grpc_with_key( url: ArbiterUrl, key: ed25519_dalek::SigningKey, address: Address, @@ -122,7 +217,7 @@ impl ArbiterSigner { receiver: response_stream, }; - authenticate(&mut transport, key).await?; + authenticate(&mut transport, &key).await?; Ok(Self { transport: Mutex::new(transport), @@ -185,7 +280,7 @@ impl ArbiterSigner { async fn authenticate( transport: &mut ClientTransport, - key: ed25519_dalek::SigningKey, + key: &ed25519_dalek::SigningKey, ) -> std::result::Result<(), ConnectError> { transport .send(ClientRequest { @@ -272,3 +367,59 @@ impl TxSigner for ArbiterSigner { self.sign_transaction_via_arbiter(tx).await } } + +#[cfg(test)] +mod tests { + use super::{FileSigningKeyStorage, SigningKeyStorage, StorageError}; + + fn unique_temp_key_path() -> std::path::PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock should be after unix epoch") + .as_nanos(); + std::env::temp_dir().join(format!( + "arbiter-client-key-{}-{}.bin", + std::process::id(), + nanos + )) + } + + #[test] + fn file_storage_creates_and_reuses_key() { + let path = unique_temp_key_path(); + let storage = FileSigningKeyStorage::new(path.clone()); + + let key_a = storage + .load_or_create() + .expect("first load_or_create should create key"); + let key_b = storage + .load_or_create() + .expect("second load_or_create should read same key"); + + assert_eq!(key_a.to_bytes(), key_b.to_bytes()); + assert!(path.exists()); + + std::fs::remove_file(path).expect("temp key file should be removable"); + } + + #[test] + fn file_storage_rejects_invalid_key_length() { + let path = unique_temp_key_path(); + std::fs::write(&path, [42u8; 31]).expect("should write invalid key file"); + let storage = FileSigningKeyStorage::new(path.clone()); + + let err = storage + .load_or_create() + .expect_err("storage should reject non-32-byte key file"); + + match err { + StorageError::InvalidKeyLength { expected, actual } => { + assert_eq!(expected, 32); + assert_eq!(actual, 31); + } + other => panic!("unexpected error: {other:?}"), + } + + std::fs::remove_file(path).expect("temp key file should be removable"); + } +} From 3993d3a8ccb0ffc294c75bf5a2e057dfa0b06884 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Thu, 19 Mar 2026 18:21:09 +0100 Subject: [PATCH 13/20] refactor(client): decouple grpc connect from wallet address and add explicit wallet configuration --- server/crates/arbiter-client/src/lib.rs | 46 ++++++++++++++++++------- 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/server/crates/arbiter-client/src/lib.rs b/server/crates/arbiter-client/src/lib.rs index 936875c..7728f39 100644 --- a/server/crates/arbiter-client/src/lib.rs +++ b/server/crates/arbiter-client/src/lib.rs @@ -145,6 +145,9 @@ enum ClientSignError { #[error("Remote signing was rejected")] Rejected, + + #[error("Wallet address is not configured")] + WalletAddressNotConfigured, } struct ClientTransport { @@ -171,32 +174,27 @@ impl ClientTransport { pub struct ArbiterSigner { transport: Mutex, - address: Address, + address: Option
, chain_id: Option, } impl ArbiterSigner { - pub async fn connect_grpc( - url: ArbiterUrl, - address: Address, - ) -> std::result::Result { + pub async fn connect_grpc(url: ArbiterUrl) -> std::result::Result { let storage = FileSigningKeyStorage::from_default_location()?; - Self::connect_grpc_with_storage(url, address, &storage).await + Self::connect_grpc_with_storage(url, &storage).await } pub async fn connect_grpc_with_storage( url: ArbiterUrl, - address: Address, storage: &S, ) -> std::result::Result { let key = storage.load_or_create()?; - Self::connect_grpc_with_key(url, key, address).await + Self::connect_grpc_with_key(url, key).await } pub async fn connect_grpc_with_key( url: ArbiterUrl, key: ed25519_dalek::SigningKey, - address: Address, ) -> std::result::Result { let anchor = webpki::anchor_from_trusted_cert(&url.ca_cert)?.to_owned(); let tls = ClientTlsConfig::new().trust_anchor(anchor); @@ -221,11 +219,29 @@ impl ArbiterSigner { Ok(Self { transport: Mutex::new(transport), - address, + address: None, chain_id: None, }) } + pub fn wallet_address(&self) -> Option
{ + self.address + } + + pub fn set_wallet_address(&mut self, address: Option
) { + self.address = address; + } + + pub fn with_wallet_address(mut self, address: Address) -> Self { + self.address = Some(address); + self + } + + pub fn with_chain_id(mut self, chain_id: ChainId) -> Self { + self.chain_id = Some(chain_id); + self + } + async fn sign_transaction_via_arbiter( &self, tx: &mut dyn SignableTransaction, @@ -242,10 +258,14 @@ impl ArbiterSigner { let mut rlp_transaction = Vec::new(); tx.encode_for_signing(&mut rlp_transaction); + let wallet_address = self + .address + .ok_or_else(|| Error::other(ClientSignError::WalletAddressNotConfigured))?; + let request = ClientRequest { payload: Some(ClientRequestPayload::EvmSignTransaction( EvmSignTransactionRequest { - wallet_address: self.address.as_slice().to_vec(), + wallet_address: wallet_address.as_slice().to_vec(), rlp_transaction, }, )), @@ -342,7 +362,7 @@ impl Signer for ArbiterSigner { } fn address(&self) -> Address { - self.address + self.address.unwrap_or(Address::ZERO) } fn chain_id(&self) -> Option { @@ -357,7 +377,7 @@ impl Signer for ArbiterSigner { #[async_trait] impl TxSigner for ArbiterSigner { fn address(&self) -> Address { - self.address + self.address.unwrap_or(Address::ZERO) } async fn sign_transaction( From ec70561c93cb56c4dc9c1c59ce5c2181c236a559 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Thu, 19 Mar 2026 19:05:56 +0100 Subject: [PATCH 14/20] refactor(arbiter-client): split auth handshake into check/do steps and simplify TxSigner signing flow --- server/crates/arbiter-client/src/lib.rs | 105 ++++++++++++++++-------- 1 file changed, 69 insertions(+), 36 deletions(-) diff --git a/server/crates/arbiter-client/src/lib.rs b/server/crates/arbiter-client/src/lib.rs index 7728f39..2c7ec5b 100644 --- a/server/crates/arbiter-client/src/lib.rs +++ b/server/crates/arbiter-client/src/lib.rs @@ -242,10 +242,10 @@ impl ArbiterSigner { self } - async fn sign_transaction_via_arbiter( + fn build_sign_transaction_request( &self, tx: &mut dyn SignableTransaction, - ) -> Result { + ) -> Result { if let Some(chain_id) = self.chain_id && !tx.set_chain_id_checked(chain_id) { @@ -262,15 +262,17 @@ impl ArbiterSigner { .address .ok_or_else(|| Error::other(ClientSignError::WalletAddressNotConfigured))?; - let request = ClientRequest { + Ok(ClientRequest { payload: Some(ClientRequestPayload::EvmSignTransaction( EvmSignTransactionRequest { wallet_address: wallet_address.as_slice().to_vec(), rlp_transaction, }, )), - }; + }) + } + async fn execute_sign_transaction_request(&self, request: ClientRequest) -> Result { let mut transport = self.transport.lock().await; transport.send(request).await.map_err(Error::other)?; let response = transport.recv().await.map_err(Error::other)?; @@ -298,7 +300,16 @@ impl ArbiterSigner { } } -async fn authenticate( +fn map_connect_error(code: i32) -> ConnectError { + match client_connect_error::Code::try_from(code).unwrap_or(client_connect_error::Code::Unknown) + { + client_connect_error::Code::ApprovalDenied => ConnectError::ApprovalDenied, + client_connect_error::Code::NoUserAgentsOnline => ConnectError::NoUserAgentsOnline, + client_connect_error::Code::Unknown => ConnectError::UnexpectedAuthResponse, + } +} + +async fn send_auth_challenge_request( transport: &mut ClientTransport, key: &ed25519_dalek::SigningKey, ) -> std::result::Result<(), ConnectError> { @@ -311,8 +322,12 @@ async fn authenticate( )), }) .await - .map_err(|_| ConnectError::UnexpectedAuthResponse)?; + .map_err(|_| ConnectError::UnexpectedAuthResponse) +} +async fn receive_auth_challenge( + transport: &mut ClientTransport, +) -> std::result::Result { let response = transport .recv() .await @@ -320,39 +335,56 @@ async fn authenticate( let payload = response.payload.ok_or(ConnectError::MissingAuthChallenge)?; match payload { - ClientResponsePayload::AuthChallenge(challenge) => { - let challenge_payload = format_challenge(challenge.nonce, &challenge.pubkey); - let signature = key.sign(&challenge_payload).to_bytes().to_vec(); - - transport - .send(ClientRequest { - payload: Some(ClientRequestPayload::AuthChallengeSolution( - AuthChallengeSolution { signature }, - )), - }) - .await - .map_err(|_| ConnectError::UnexpectedAuthResponse)?; - - // Current server flow does not emit `AuthOk` for SDK clients, so we proceed after - // sending the solution. If authentication fails, the first business request will return - // a `ClientConnectError` or the stream will close. - Ok(()) - } - ClientResponsePayload::ClientConnectError(err) => { - match client_connect_error::Code::try_from(err.code) - .unwrap_or(client_connect_error::Code::Unknown) - { - client_connect_error::Code::ApprovalDenied => Err(ConnectError::ApprovalDenied), - client_connect_error::Code::NoUserAgentsOnline => { - Err(ConnectError::NoUserAgentsOnline) - } - client_connect_error::Code::Unknown => Err(ConnectError::UnexpectedAuthResponse), - } - } + ClientResponsePayload::AuthChallenge(challenge) => Ok(challenge), + ClientResponsePayload::ClientConnectError(err) => Err(map_connect_error(err.code)), _ => Err(ConnectError::UnexpectedAuthResponse), } } +async fn send_auth_challenge_solution( + transport: &mut ClientTransport, + key: &ed25519_dalek::SigningKey, + challenge: arbiter_proto::proto::client::AuthChallenge, +) -> std::result::Result<(), ConnectError> { + let challenge_payload = format_challenge(challenge.nonce, &challenge.pubkey); + let signature = key.sign(&challenge_payload).to_bytes().to_vec(); + + transport + .send(ClientRequest { + payload: Some(ClientRequestPayload::AuthChallengeSolution( + AuthChallengeSolution { signature }, + )), + }) + .await + .map_err(|_| ConnectError::UnexpectedAuthResponse) +} + +async fn receive_auth_confirmation( + transport: &mut ClientTransport, +) -> std::result::Result<(), ConnectError> { + let response = transport + .recv() + .await + .map_err(|_| ConnectError::UnexpectedAuthResponse)?; + + let payload = response.payload.ok_or(ConnectError::UnexpectedAuthResponse)?; + match payload { + ClientResponsePayload::AuthOk(_) => Ok(()), + ClientResponsePayload::ClientConnectError(err) => Err(map_connect_error(err.code)), + _ => Err(ConnectError::UnexpectedAuthResponse), + } +} + +async fn authenticate( + transport: &mut ClientTransport, + key: &ed25519_dalek::SigningKey, +) -> std::result::Result<(), ConnectError> { + send_auth_challenge_request(transport, key).await?; + let challenge = receive_auth_challenge(transport).await?; + send_auth_challenge_solution(transport, key, challenge).await?; + receive_auth_confirmation(transport).await +} + #[async_trait] impl Signer for ArbiterSigner { async fn sign_hash(&self, _hash: &B256) -> Result { @@ -384,7 +416,8 @@ impl TxSigner for ArbiterSigner { &self, tx: &mut dyn SignableTransaction, ) -> Result { - self.sign_transaction_via_arbiter(tx).await + let request = self.build_sign_transaction_request(tx)?; + self.execute_sign_transaction_request(request).await } } From e1a8553142c9c9be849e17a3c42edbbd09950238 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Thu, 19 Mar 2026 19:06:27 +0100 Subject: [PATCH 15/20] feat(client-auth): emit and require AuthOk for SDK client challenge flow --- server/crates/arbiter-client/src/lib.rs | 4 +- .../arbiter-server/src/actors/client/auth.rs | 51 +++++++++++++++---- .../arbiter-server/tests/client/auth.rs | 18 +++++++ 3 files changed, 63 insertions(+), 10 deletions(-) diff --git a/server/crates/arbiter-client/src/lib.rs b/server/crates/arbiter-client/src/lib.rs index 2c7ec5b..cd65220 100644 --- a/server/crates/arbiter-client/src/lib.rs +++ b/server/crates/arbiter-client/src/lib.rs @@ -367,7 +367,9 @@ async fn receive_auth_confirmation( .await .map_err(|_| ConnectError::UnexpectedAuthResponse)?; - let payload = response.payload.ok_or(ConnectError::UnexpectedAuthResponse)?; + let payload = response + .payload + .ok_or(ConnectError::UnexpectedAuthResponse)?; match payload { ClientResponsePayload::AuthOk(_) => Ok(()), ClientResponsePayload::ClientConnectError(err) => Err(map_connect_error(err.code)), diff --git a/server/crates/arbiter-server/src/actors/client/auth.rs b/server/crates/arbiter-server/src/actors/client/auth.rs index 55acb4c..18386d8 100644 --- a/server/crates/arbiter-server/src/actors/client/auth.rs +++ b/server/crates/arbiter-server/src/actors/client/auth.rs @@ -1,8 +1,8 @@ use arbiter_proto::{ format_challenge, proto::client::{ - AuthChallenge, AuthChallengeSolution, ClientConnectError, ClientRequest, ClientResponse, - client_connect_error::Code as ConnectErrorCode, + AuthChallenge, AuthChallengeSolution, AuthOk, ClientConnectError, ClientRequest, + ClientResponse, client_connect_error::Code as ConnectErrorCode, client_request::Payload as ClientRequestPayload, client_response::Payload as ClientResponsePayload, }, @@ -26,6 +26,25 @@ use crate::{ use super::session::ClientSession; +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ClientId(i32); + +impl ClientId { + pub fn new(raw: i32) -> Self { + Self(raw) + } + + pub fn as_i32(self) -> i32 { + self.0 + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct ClientNonceState { + client_id: ClientId, + nonce: i32, +} + #[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)] pub enum Error { #[error("Unexpected message payload")] @@ -63,7 +82,7 @@ pub enum ApproveError { async fn get_nonce( db: &db::DatabasePool, pubkey: &VerifyingKey, -) -> Result, Error> { +) -> Result, Error> { let pubkey_bytes = pubkey.as_bytes().to_vec(); let mut conn = db.get().await.map_err(|e| { @@ -90,7 +109,10 @@ async fn get_nonce( .execute(conn) .await?; - Ok(Some((client_id, current_nonce))) + Ok(Some(ClientNonceState { + client_id: ClientId::new(client_id), + nonce: current_nonce, + })) }) }) .await @@ -126,7 +148,7 @@ async fn approve_new_client( } enum InsertClientResult { - Inserted(i32), + Inserted(ClientId), AlreadyExists, } @@ -176,7 +198,7 @@ async fn insert_client( Error::DatabaseOperationFailed })?; - Ok(InsertClientResult::Inserted(client_id)) + Ok(InsertClientResult::Inserted(ClientId::new(client_id))) } async fn challenge_client( @@ -224,6 +246,17 @@ async fn challenge_client( Error::InvalidChallengeSolution })?; + props + .transport + .send(Ok(ClientResponse { + payload: Some(ClientResponsePayload::AuthOk(AuthOk {})), + })) + .await + .map_err(|e| { + error!(error = ?e, "Failed to send auth ok"); + Error::Transport + })?; + Ok(()) } @@ -237,7 +270,7 @@ fn connect_error_code(err: &Error) -> ConnectErrorCode { } } -async fn authenticate(props: &mut ClientConnection) -> Result<(VerifyingKey, i32), Error> { +async fn authenticate(props: &mut ClientConnection) -> Result<(VerifyingKey, ClientId), Error> { let Some(ClientRequest { payload: Some(ClientRequestPayload::AuthChallengeRequest(challenge)), }) = props.transport.recv().await @@ -253,13 +286,13 @@ async fn authenticate(props: &mut ClientConnection) -> Result<(VerifyingKey, i32 VerifyingKey::from_bytes(pubkey_bytes).map_err(|_| Error::InvalidAuthPubkeyEncoding)?; let (client_id, nonce) = match get_nonce(&props.db, &pubkey).await? { - Some((client_id, nonce)) => (client_id, nonce), + Some(state) => (state.client_id, state.nonce), None => { approve_new_client(&props.actors, pubkey).await?; match insert_client(&props.db, &pubkey).await? { InsertClientResult::Inserted(client_id) => (client_id, 0), InsertClientResult::AlreadyExists => match get_nonce(&props.db, &pubkey).await? { - Some((client_id, nonce)) => (client_id, nonce), + Some(state) => (state.client_id, state.nonce), None => return Err(Error::InternalError), }, } diff --git a/server/crates/arbiter-server/tests/client/auth.rs b/server/crates/arbiter-server/tests/client/auth.rs index 5d82423..45b7335 100644 --- a/server/crates/arbiter-server/tests/client/auth.rs +++ b/server/crates/arbiter-server/tests/client/auth.rs @@ -114,6 +114,15 @@ pub async fn test_challenge_auth() { .await .unwrap(); + let response = test_transport.recv().await.expect("should receive auth ok"); + match response { + Ok(resp) => match resp.payload { + Some(ClientResponsePayload::AuthOk(_)) => {} + other => panic!("Expected AuthOk, got {other:?}"), + }, + Err(err) => panic!("Expected Ok response, got Err({err:?})"), + } + // Auth completes, session spawned task.await.unwrap(); } @@ -178,6 +187,15 @@ pub async fn test_evm_sign_request_payload_is_handled() { .await .unwrap(); + let response = test_transport.recv().await.expect("should receive auth ok"); + match response { + Ok(resp) => match resp.payload { + Some(ClientResponsePayload::AuthOk(_)) => {} + other => panic!("Expected AuthOk, got {other:?}"), + }, + Err(err) => panic!("Expected Ok response, got Err({err:?})"), + } + task.await.unwrap(); let tx = TxEip1559 { From 971db0e91997b1cbf56e3347a59097425821a200 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Thu, 19 Mar 2026 19:07:19 +0100 Subject: [PATCH 16/20] refactor(client-auth): introduce ClientId newtype to avoid client_id/nonce confusion refactor(user-agent): replace manual terminality helper with fatality::Fatality --- server/Cargo.lock | 139 +++++++++++++----- server/crates/arbiter-server/Cargo.toml | 1 + .../src/actors/client/session.rs | 10 +- .../src/actors/user_agent/mod.rs | 5 +- 4 files changed, 112 insertions(+), 43 deletions(-) diff --git a/server/Cargo.lock b/server/Cargo.lock index 061f1a2..80b177a 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -100,7 +100,7 @@ dependencies = [ "serde", "serde_json", "serde_with", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -136,7 +136,7 @@ dependencies = [ "futures", "futures-util", "serde_json", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -178,7 +178,7 @@ dependencies = [ "alloy-rlp", "crc", "serde", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -203,7 +203,7 @@ dependencies = [ "alloy-rlp", "borsh", "serde", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -239,7 +239,7 @@ dependencies = [ "serde", "serde_with", "sha2 0.10.9", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -280,7 +280,7 @@ dependencies = [ "http", "serde", "serde_json", - "thiserror", + "thiserror 2.0.18", "tracing", ] @@ -307,7 +307,7 @@ dependencies = [ "futures-utils-wasm", "serde", "serde_json", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -382,7 +382,7 @@ dependencies = [ "reqwest", "serde", "serde_json", - "thiserror", + "thiserror 2.0.18", "tokio", "tracing", "url", @@ -475,7 +475,7 @@ dependencies = [ "serde", "serde_json", "serde_with", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -501,7 +501,7 @@ dependencies = [ "either", "elliptic-curve", "k256", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -517,7 +517,7 @@ dependencies = [ "async-trait", "k256", "rand 0.8.5", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -608,7 +608,7 @@ dependencies = [ "parking_lot", "serde", "serde_json", - "thiserror", + "thiserror 2.0.18", "tokio", "tower", "tracing", @@ -644,7 +644,7 @@ dependencies = [ "nybbles", "serde", "smallvec", - "thiserror", + "thiserror 2.0.18", "tracing", ] @@ -684,8 +684,9 @@ dependencies = [ "async-trait", "ed25519-dalek", "http", + "rand 0.10.0", "rustls-webpki", - "thiserror", + "thiserror 2.0.18", "tokio", "tokio-stream", "tonic", @@ -708,7 +709,7 @@ dependencies = [ "rcgen", "rstest", "rustls-pki-types", - "thiserror", + "thiserror 2.0.18", "tokio", "tonic", "tonic-prost", @@ -733,6 +734,7 @@ dependencies = [ "diesel-async", "diesel_migrations", "ed25519-dalek", + "fatality", "futures", "insta", "k256", @@ -751,7 +753,7 @@ dependencies = [ "spki", "strum", "test-log", - "thiserror", + "thiserror 2.0.18", "tokio", "tokio-stream", "tonic", @@ -761,13 +763,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "arbiter-terrors-poc" -version = "0.1.0" -dependencies = [ - "terrors", -] - [[package]] name = "arbiter-tokens-registry" version = "0.1.0" @@ -791,7 +786,7 @@ dependencies = [ "sha2 0.10.9", "smlang", "spki", - "thiserror", + "thiserror 2.0.18", "tokio", "tokio-stream", "tonic", @@ -1019,7 +1014,7 @@ dependencies = [ "nom", "num-traits", "rusticata-macros", - "thiserror", + "thiserror 2.0.18", "time", ] @@ -2079,6 +2074,21 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "expander" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2c470c71d91ecbd179935b24170459e926382eaaa86b590b78814e180d8a8e2" +dependencies = [ + "blake2", + "file-guard", + "fs-err", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -2107,6 +2117,30 @@ dependencies = [ "bytes", ] +[[package]] +name = "fatality" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec6f82451ff7f0568c6181287189126d492b5654e30a788add08027b6363d019" +dependencies = [ + "fatality-proc-macro", + "thiserror 1.0.69", +] + +[[package]] +name = "fatality-proc-macro" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb42427514b063d97ce21d5199f36c0c307d981434a6be32582bc79fe5bd2303" +dependencies = [ + "expander", + "indexmap 2.13.0", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "ff" version = "0.13.1" @@ -2129,6 +2163,16 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" +[[package]] +name = "file-guard" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21ef72acf95ec3d7dbf61275be556299490a245f017cf084bd23b4f68cf9407c" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -2190,6 +2234,15 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs-err" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a41f105fe1d5b6b34b2055e3dc59bb79b46b48b2040b9e6c7b4b5de097aa41" +dependencies = [ + "autocfg", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -3784,7 +3837,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror", + "thiserror 2.0.18", "tokio", "tracing", "web-time", @@ -3805,7 +3858,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -4140,7 +4193,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d" dependencies = [ "hashbrown 0.16.1", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -4863,12 +4916,6 @@ dependencies = [ "windows-sys 0.60.2", ] -[[package]] -name = "terrors" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "987fd8c678ca950df2a18b2c6e9da6ca511d449278fab3565efe0d49c0c07a5d" - [[package]] name = "test-log" version = "0.2.19" @@ -4900,13 +4947,33 @@ dependencies = [ "unicode-width 0.2.2", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -6000,7 +6067,7 @@ dependencies = [ "nom", "oid-registry", "rusticata-macros", - "thiserror", + "thiserror 2.0.18", "time", ] diff --git a/server/crates/arbiter-server/Cargo.toml b/server/crates/arbiter-server/Cargo.toml index 4629d81..89650fd 100644 --- a/server/crates/arbiter-server/Cargo.toml +++ b/server/crates/arbiter-server/Cargo.toml @@ -27,6 +27,7 @@ rustls.workspace = true smlang.workspace = true miette.workspace = true thiserror.workspace = true +fatality = "0.1.1" diesel_migrations = { version = "2.3.1", features = ["sqlite"] } async-trait.workspace = true secrecy = "0.10.3" diff --git a/server/crates/arbiter-server/src/actors/client/session.rs b/server/crates/arbiter-server/src/actors/client/session.rs index 63d19b6..d3d3bc2 100644 --- a/server/crates/arbiter-server/src/actors/client/session.rs +++ b/server/crates/arbiter-server/src/actors/client/session.rs @@ -15,7 +15,7 @@ use tracing::{error, info}; use crate::{ actors::{ GlobalActors, - client::{ClientConnection, ClientError}, + client::{ClientConnection, ClientError, auth::ClientId}, evm::ClientSignTransaction, router::RegisterClient, }, @@ -24,11 +24,11 @@ use crate::{ pub struct ClientSession { props: ClientConnection, - client_id: i32, + client_id: ClientId, } impl ClientSession { - pub(crate) fn new(props: ClientConnection, client_id: i32) -> Self { + pub(crate) fn new(props: ClientConnection, client_id: ClientId) -> Self { Self { props, client_id } } @@ -54,7 +54,7 @@ impl ClientSession { .actors .evm .ask(ClientSignTransaction { - client_id: self.client_id, + client_id: self.client_id.as_i32(), wallet_address: Address::from_slice(&wallet_address), transaction: tx, }) @@ -145,7 +145,7 @@ impl ClientSession { let props = ClientConnection::new(db, transport, actors); Self { props, - client_id: 0, + client_id: ClientId::new(0), } } } diff --git a/server/crates/arbiter-server/src/actors/user_agent/mod.rs b/server/crates/arbiter-server/src/actors/user_agent/mod.rs index 6793981..a125c22 100644 --- a/server/crates/arbiter-server/src/actors/user_agent/mod.rs +++ b/server/crates/arbiter-server/src/actors/user_agent/mod.rs @@ -4,6 +4,7 @@ use arbiter_proto::{ }, transport::Bi, }; +use fatality::Fatality; use kameo::actor::Spawn as _; use tracing::{error, info}; @@ -38,8 +39,8 @@ pub enum TransportResponseError { ConnectionRegistrationFailed, } -impl TransportResponseError { - pub fn is_terminal(&self) -> bool { +impl Fatality for TransportResponseError { + fn is_fatal(&self) -> bool { !matches!( self, Self::SdkClientApprove(_) | Self::SdkClientList(_) | Self::SdkClientRevoke(_) From 784261f4d89174532f81b622a8cc13147f5877be Mon Sep 17 00:00:00 2001 From: CleverWild Date: Thu, 19 Mar 2026 19:07:28 +0100 Subject: [PATCH 17/20] perf(user-agent): use sqlite INSERT ... RETURNING for sdk client approve --- .../src/actors/user_agent/session.rs | 48 +++++++------------ 1 file changed, 17 insertions(+), 31 deletions(-) diff --git a/server/crates/arbiter-server/src/actors/user_agent/session.rs b/server/crates/arbiter-server/src/actors/user_agent/session.rs index 9316f80..f9ed10d 100644 --- a/server/crates/arbiter-server/src/actors/user_agent/session.rs +++ b/server/crates/arbiter-server/src/actors/user_agent/session.rs @@ -16,6 +16,7 @@ use chacha20poly1305::{AeadInPlace, XChaCha20Poly1305, XNonce, aead::KeyInit}; use diesel::{ExpressionMethods as _, QueryDsl as _, dsl::insert_into}; use diesel_async::RunQueryDsl as _; use ed25519_dalek::VerifyingKey; +use fatality::Fatality; use kameo::{Actor, error::SendError, messages, prelude::Context}; use memsafe::MemSafe; use tokio::{select, sync::watch}; @@ -380,39 +381,24 @@ impl UserAgentSession { program_client::created_at.eq(now), program_client::updated_at.eq(now), )) - .execute(&mut conn) + .returning(( + program_client::id, + program_client::public_key, + program_client::created_at, + )) + .get_result::<(i32, Vec, i32)>(&mut conn) .await; match insert_result { - Ok(_) => { - match program_client::table - .filter(program_client::public_key.eq(&pubkey_bytes)) - .order(program_client::id.desc()) - .select(( - program_client::id, - program_client::public_key, - program_client::created_at, - )) - .first::<(i32, Vec, i32)>(&mut conn) - .await - { - Ok((id, pubkey, created_at)) => Ok(response( - UserAgentResponsePayload::SdkClientApprove(SdkClientApproveResponse { - result: Some(ApproveResult::Client(SdkClientEntry { - id, - pubkey, - created_at, - })), - }), - )), - Err(e) => { - error!(?e, "Failed to fetch inserted SDK client"); - Err(TransportResponseError::SdkClientApprove( - ProtoSdkClientError::Internal, - )) - } - } - } + Ok((id, pubkey, created_at)) => Ok(response( + UserAgentResponsePayload::SdkClientApprove(SdkClientApproveResponse { + result: Some(ApproveResult::Client(SdkClientEntry { + id, + pubkey, + created_at, + })), + }), + )), Err(diesel::result::Error::DatabaseError( diesel::result::DatabaseErrorKind::UniqueViolation, _, @@ -574,7 +560,7 @@ impl Actor for UserAgentSession { } } Err(err) => { - let should_stop = err.is_terminal(); + let should_stop = err.is_fatal(); if self.props.transport.send(Err(err)).await.is_err() { error!(actor = "useragent", reason = "channel closed", "send.failed"); return Some(kameo::mailbox::Signal::Stop); From e135519c06d905640a8f01de0b89cafbd10fd20e Mon Sep 17 00:00:00 2001 From: hdbg Date: Sun, 22 Mar 2026 00:10:06 +0100 Subject: [PATCH 18/20] chore(deps): update Rust dependencies and add cargo-edit --- mise.lock | 4 + mise.toml | 1 + server/Cargo.lock | 145 ++++++++++++++---------- server/Cargo.toml | 10 +- server/crates/arbiter-client/Cargo.toml | 2 +- server/crates/arbiter-proto/Cargo.toml | 4 +- server/crates/arbiter-server/Cargo.toml | 6 +- 7 files changed, 100 insertions(+), 72 deletions(-) diff --git a/mise.lock b/mise.lock index 411b280..3cd025e 100644 --- a/mise.lock +++ b/mise.lock @@ -28,6 +28,10 @@ url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-x86_64- version = "0.22.1" backend = "cargo:cargo-audit" +[[tools."cargo:cargo-edit"]] +version = "0.13.9" +backend = "cargo:cargo-edit" + [[tools."cargo:cargo-features"]] version = "1.0.0" backend = "cargo:cargo-features" diff --git a/mise.toml b/mise.toml index 9e1e802..3a04a6f 100644 --- a/mise.toml +++ b/mise.toml @@ -11,6 +11,7 @@ protoc = "29.6" "cargo:cargo-insta" = "1.46.3" python = "3.14.3" ast-grep = "0.42.0" +"cargo:cargo-edit" = "0.13.9" [tasks.codegen] sources = ['protobufs/*.proto'] diff --git a/server/Cargo.lock b/server/Cargo.lock index 1eec65a..32a1587 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -67,13 +67,13 @@ dependencies = [ [[package]] name = "alloy-chains" -version = "0.2.31" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d9d22005bf31b018f31ef9ecadb5d2c39cf4f6acc8db0456f72c815f3d7f757" +checksum = "9247f0a399ef71aeb68f497b2b8fb348014f742b50d3b83b1e00dfe1b7d64b3d" dependencies = [ "alloy-primitives", "num_enum", - "strum", + "strum 0.27.2", ] [[package]] @@ -165,7 +165,7 @@ dependencies = [ "itoa", "serde", "serde_json", - "winnow", + "winnow 0.7.15", ] [[package]] @@ -471,7 +471,7 @@ dependencies = [ "alloy-rlp", "alloy-serde", "alloy-sol-types", - "itertools 0.14.0", + "itertools 0.13.0", "serde", "serde_json", "serde_with", @@ -578,7 +578,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6df77fea9d6a2a75c0ef8d2acbdfd92286cc599983d3175ccdc170d3433d249" dependencies = [ "serde", - "winnow", + "winnow 0.7.15", ] [[package]] @@ -624,7 +624,7 @@ checksum = "aa501ad58dd20acddbfebc65b52e60f05ebf97c52fa40d1b35e91f5e2da0ad0e" dependencies = [ "alloy-json-rpc", "alloy-transport", - "itertools 0.14.0", + "itertools 0.13.0", "reqwest", "serde_json", "tower", @@ -753,7 +753,7 @@ dependencies = [ "sha2 0.10.9", "smlang", "spki", - "strum", + "strum 0.28.0", "test-log", "thiserror 2.0.18", "tokio", @@ -1077,9 +1077,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "aws-lc-rs" -version = "1.16.1" +version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94bffc006df10ac2a68c83692d734a465f8ee6c5b384d8545a636f81d858f4bf" +checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -1088,9 +1088,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.38.0" +version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4321e568ed89bb5a7d291a7f37997c2c0df89809d7b6d12062c81ddb54aa782e" +checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a" dependencies = [ "cc", "cmake", @@ -1285,19 +1285,20 @@ dependencies = [ [[package]] name = "borsh" -version = "1.6.0" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1da5ab77c1437701eeff7c88d968729e7766172279eab0676857b3d63af7a6f" +checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" dependencies = [ "borsh-derive", + "bytes", "cfg_aliases", ] [[package]] name = "borsh-derive" -version = "1.6.0" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0686c856aa6aac0c4498f936d7d6a02df690f614c03e4d906d1018062b5c5e2c" +checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59" dependencies = [ "once_cell", "proc-macro-crate", @@ -1811,15 +1812,16 @@ dependencies = [ [[package]] name = "diesel-async" -version = "0.7.4" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13096fb8dae53f2d411c4b523bec85f45552ed3044a2ab4d85fb2092d9cb4f34" +checksum = "b95864e58597509106f1fddfe0600de7e589e1fddddd87f54eee0a49fd111bbc" dependencies = [ "bb8", "diesel", "diesel_migrations", "futures-core", "futures-util", + "pin-project-lite", "scoped-futures", "tokio", ] @@ -2049,7 +2051,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2869,20 +2871,11 @@ dependencies = [ "either", ] -[[package]] -name = "itertools" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" -dependencies = [ - "either", -] - [[package]] name = "itoa" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jobserver" @@ -3193,7 +3186,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3270,9 +3263,9 @@ dependencies = [ [[package]] name = "num_enum" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" dependencies = [ "num_enum_derive", "rustversion", @@ -3280,9 +3273,9 @@ dependencies = [ [[package]] name = "num_enum_derive" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ "proc-macro2", "quote", @@ -3674,7 +3667,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" dependencies = [ "heck", - "itertools 0.14.0", + "itertools 0.13.0", "log", "multimap", "petgraph", @@ -3695,7 +3688,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" dependencies = [ "anyhow", - "itertools 0.14.0", + "itertools 0.13.0", "proc-macro2", "quote", "syn 2.0.117", @@ -3777,9 +3770,9 @@ checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3" [[package]] name = "pulldown-cmark" -version = "0.13.1" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83c41efbf8f90ac44de7f3a868f0867851d261b56291732d0cbf7cceaaeb55a6" +checksum = "14104c5a24d9bcf7eb2c24753e0f49fe14555d8bd565ea3d38e4b4303267259d" dependencies = [ "bitflags", "memchr", @@ -4292,7 +4285,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4323,9 +4316,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.9" +version = "0.103.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" dependencies = [ "aws-lc-rs", "ring", @@ -4708,7 +4701,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4769,7 +4762,16 @@ version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ - "strum_macros", + "strum_macros 0.27.2", +] + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros 0.28.0", ] [[package]] @@ -4784,6 +4786,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "subtle" version = "2.6.1" @@ -4881,7 +4895,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5026,9 +5040,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ "tinyvec_macros", ] @@ -5113,7 +5127,7 @@ dependencies = [ "serde_spanned", "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", - "winnow", + "winnow 0.7.15", ] [[package]] @@ -5127,32 +5141,32 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.0.0+spec-1.1.0" +version = "1.0.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" +checksum = "9b320e741db58cac564e26c607d3cc1fdc4a88fd36c879568c07856ed83ff3e9" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.25.4+spec-1.1.0" +version = "0.25.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2" +checksum = "8ca1a40644a28bce036923f6a431df0b34236949d111cc07cb6dca830c9ef2e1" dependencies = [ "indexmap 2.13.0", - "toml_datetime 1.0.0+spec-1.1.0", + "toml_datetime 1.0.1+spec-1.1.0", "toml_parser", - "winnow", + "winnow 1.0.0", ] [[package]] name = "toml_parser" -version = "1.0.9+spec-1.1.0" +version = "1.0.10+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +checksum = "7df25b4befd31c4816df190124375d5a20c6b6921e2cad937316de3fccd63420" dependencies = [ - "winnow", + "winnow 1.0.0", ] [[package]] @@ -5916,6 +5930,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -6083,18 +6106,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.42" +version = "0.8.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" +checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.42" +version = "0.8.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" +checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" dependencies = [ "proc-macro2", "quote", diff --git a/server/Cargo.toml b/server/Cargo.toml index 6556f86..ddc9416 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -9,23 +9,23 @@ disallowed-methods = "deny" [workspace.dependencies] -tonic = { version = "0.14.3", features = [ +tonic = { version = "0.14.5", features = [ "deflate", "gzip", "tls-connect-info", "zstd", ] } tracing = "0.1.44" -tokio = { version = "1.49.0", features = ["full"] } +tokio = { version = "1.50.0", features = ["full"] } ed25519-dalek = { version = "3.0.0-pre.6", features = ["rand_core"] } -chrono = { version = "0.4.43", features = ["serde"] } +chrono = { version = "0.4.44", features = ["serde"] } rand = "0.10.0" -rustls = { version = "0.23.36", features = ["aws-lc-rs"] } +rustls = { version = "0.23.37", features = ["aws-lc-rs"] } smlang = "0.8.0" miette = { version = "7.6.0", features = ["fancy", "serde"] } thiserror = "2.0.18" async-trait = "0.1.89" -futures = "0.3.31" +futures = "0.3.32" tokio-stream = { version = "0.1.18", features = ["full"] } kameo = "0.19.2" prost-types = { version = "0.14.3", features = ["chrono"] } diff --git a/server/crates/arbiter-client/Cargo.toml b/server/crates/arbiter-client/Cargo.toml index e035fd2..163c3f1 100644 --- a/server/crates/arbiter-client/Cargo.toml +++ b/server/crates/arbiter-client/Cargo.toml @@ -18,6 +18,6 @@ tokio-stream.workspace = true ed25519-dalek.workspace = true thiserror.workspace = true http = "1.4.0" -rustls-webpki = { version = "0.103.9", features = ["aws-lc-rs"] } +rustls-webpki = { version = "0.103.10", features = ["aws-lc-rs"] } async-trait.workspace = true rand.workspace = true diff --git a/server/crates/arbiter-proto/Cargo.toml b/server/crates/arbiter-proto/Cargo.toml index b179328..8299691 100644 --- a/server/crates/arbiter-proto/Cargo.toml +++ b/server/crates/arbiter-proto/Cargo.toml @@ -10,7 +10,7 @@ tonic.workspace = true tokio.workspace = true futures.workspace = true hex = "0.4.3" -tonic-prost = "0.14.3" +tonic-prost = "0.14.5" prost = "0.14.3" kameo.workspace = true url = "2.5.8" @@ -24,7 +24,7 @@ async-trait.workspace = true tokio-stream.workspace = true [build-dependencies] -tonic-prost-build = "0.14.3" +tonic-prost-build = "0.14.5" protoc-bin-vendored = "3" [dev-dependencies] diff --git a/server/crates/arbiter-server/Cargo.toml b/server/crates/arbiter-server/Cargo.toml index 8d8e087..8996fce 100644 --- a/server/crates/arbiter-server/Cargo.toml +++ b/server/crates/arbiter-server/Cargo.toml @@ -9,8 +9,8 @@ license = "Apache-2.0" workspace = true [dependencies] -diesel = { version = "2.3.6", features = ["chrono", "returning_clauses_for_sqlite_3_35", "serde_json", "time", "uuid"] } -diesel-async = { version = "0.7.4", features = [ +diesel = { version = "2.3.7", features = ["chrono", "returning_clauses_for_sqlite_3_35", "serde_json", "time", "uuid"] } +diesel-async = { version = "0.8.0", features = [ "bb8", "migrations", "sqlite", @@ -44,7 +44,7 @@ x25519-dalek.workspace = true chacha20poly1305 = { version = "0.10.1", features = ["std"] } argon2 = { version = "0.5.3", features = ["zeroize"] } restructed = "0.2.2" -strum = { version = "0.27.2", features = ["derive"] } +strum = { version = "0.28.0", features = ["derive"] } pem = "3.0.6" k256.workspace = true rsa.workspace = true From 1f07fd6a982a39db8d93800e4e63a14dece7c295 Mon Sep 17 00:00:00 2001 From: hdbg Date: Sun, 22 Mar 2026 11:55:39 +0100 Subject: [PATCH 19/20] refactor(client): split into more modules --- server/crates/arbiter-client/src/auth.rs | 140 ++++++ server/crates/arbiter-client/src/lib.rs | 463 +----------------- server/crates/arbiter-client/src/signer.rs | 153 ++++++ server/crates/arbiter-client/src/storage.rs | 132 +++++ server/crates/arbiter-client/src/transport.rs | 51 ++ 5 files changed, 484 insertions(+), 455 deletions(-) create mode 100644 server/crates/arbiter-client/src/auth.rs create mode 100644 server/crates/arbiter-client/src/signer.rs create mode 100644 server/crates/arbiter-client/src/storage.rs create mode 100644 server/crates/arbiter-client/src/transport.rs diff --git a/server/crates/arbiter-client/src/auth.rs b/server/crates/arbiter-client/src/auth.rs new file mode 100644 index 0000000..349c148 --- /dev/null +++ b/server/crates/arbiter-client/src/auth.rs @@ -0,0 +1,140 @@ +use arbiter_proto::{ + format_challenge, + proto::client::{ + AuthChallengeRequest, AuthChallengeSolution, AuthResult, ClientRequest, + client_request::Payload as ClientRequestPayload, + client_response::Payload as ClientResponsePayload, + }, +}; +use ed25519_dalek::Signer as _; + +use crate::{ + storage::StorageError, + transport::{ClientTransport, next_request_id}, +}; + +#[derive(Debug, thiserror::Error)] +pub enum ConnectError { + #[error("Could not establish connection")] + Connection(#[from] tonic::transport::Error), + + #[error("Invalid server URI")] + InvalidUri(#[from] http::uri::InvalidUri), + + #[error("Invalid CA certificate")] + InvalidCaCert(#[from] webpki::Error), + + #[error("gRPC error")] + Grpc(#[from] tonic::Status), + + #[error("Auth challenge was not returned by server")] + MissingAuthChallenge, + + #[error("Client approval denied by User Agent")] + ApprovalDenied, + + #[error("No User Agents online to approve client")] + NoUserAgentsOnline, + + #[error("Unexpected auth response payload")] + UnexpectedAuthResponse, + + #[error("Signing key storage error")] + Storage(#[from] StorageError), +} + +fn map_auth_result(code: i32) -> ConnectError { + match AuthResult::try_from(code).unwrap_or(AuthResult::Unspecified) { + AuthResult::ApprovalDenied => ConnectError::ApprovalDenied, + AuthResult::NoUserAgentsOnline => ConnectError::NoUserAgentsOnline, + AuthResult::Unspecified + | AuthResult::Success + | AuthResult::InvalidKey + | AuthResult::InvalidSignature + | AuthResult::Internal => ConnectError::UnexpectedAuthResponse, + } +} + +async fn send_auth_challenge_request( + transport: &mut ClientTransport, + key: &ed25519_dalek::SigningKey, +) -> std::result::Result<(), ConnectError> { + transport + .send(ClientRequest { + request_id: next_request_id(), + payload: Some(ClientRequestPayload::AuthChallengeRequest( + AuthChallengeRequest { + pubkey: key.verifying_key().to_bytes().to_vec(), + }, + )), + }) + .await + .map_err(|_| ConnectError::UnexpectedAuthResponse) +} + +async fn receive_auth_challenge( + transport: &mut ClientTransport, +) -> std::result::Result { + let response = transport + .recv() + .await + .map_err(|_| ConnectError::MissingAuthChallenge)?; + + let payload = response.payload.ok_or(ConnectError::MissingAuthChallenge)?; + match payload { + ClientResponsePayload::AuthChallenge(challenge) => Ok(challenge), + ClientResponsePayload::AuthResult(result) => Err(map_auth_result(result)), + _ => Err(ConnectError::UnexpectedAuthResponse), + } +} + +async fn send_auth_challenge_solution( + transport: &mut ClientTransport, + key: &ed25519_dalek::SigningKey, + challenge: arbiter_proto::proto::client::AuthChallenge, +) -> std::result::Result<(), ConnectError> { + let challenge_payload = format_challenge(challenge.nonce, &challenge.pubkey); + let signature = key.sign(&challenge_payload).to_bytes().to_vec(); + + transport + .send(ClientRequest { + request_id: next_request_id(), + payload: Some(ClientRequestPayload::AuthChallengeSolution( + AuthChallengeSolution { signature }, + )), + }) + .await + .map_err(|_| ConnectError::UnexpectedAuthResponse) +} + +async fn receive_auth_confirmation( + transport: &mut ClientTransport, +) -> std::result::Result<(), ConnectError> { + let response = transport + .recv() + .await + .map_err(|_| ConnectError::UnexpectedAuthResponse)?; + + let payload = response + .payload + .ok_or(ConnectError::UnexpectedAuthResponse)?; + match payload { + ClientResponsePayload::AuthResult(result) + if AuthResult::try_from(result).ok() == Some(AuthResult::Success) => + { + Ok(()) + } + ClientResponsePayload::AuthResult(result) => Err(map_auth_result(result)), + _ => Err(ConnectError::UnexpectedAuthResponse), + } +} + +pub(crate) async fn authenticate( + transport: &mut ClientTransport, + key: &ed25519_dalek::SigningKey, +) -> std::result::Result<(), ConnectError> { + send_auth_challenge_request(transport, key).await?; + let challenge = receive_auth_challenge(transport).await?; + send_auth_challenge_solution(transport, key, challenge).await?; + receive_auth_confirmation(transport).await +} diff --git a/server/crates/arbiter-client/src/lib.rs b/server/crates/arbiter-client/src/lib.rs index d2347ff..f98d107 100644 --- a/server/crates/arbiter-client/src/lib.rs +++ b/server/crates/arbiter-client/src/lib.rs @@ -1,455 +1,8 @@ -use alloy::{ - consensus::SignableTransaction, - network::TxSigner, - primitives::{Address, B256, ChainId, Signature}, - signers::{Error, Result, Signer}, -}; -use arbiter_proto::{ - format_challenge, home_path, - proto::{ - arbiter_service_client::ArbiterServiceClient, - client::{ - AuthChallengeRequest, AuthChallengeSolution, AuthResult, ClientRequest, ClientResponse, - client_request::Payload as ClientRequestPayload, - client_response::Payload as ClientResponsePayload, - }, - }, - url::ArbiterUrl, -}; -use async_trait::async_trait; -use ed25519_dalek::Signer as _; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicI32, Ordering}; -use tokio::sync::{Mutex, mpsc}; -use tokio_stream::wrappers::ReceiverStream; -use tonic::transport::ClientTlsConfig; - -const BUFFER_LENGTH: usize = 16; -static NEXT_REQUEST_ID: AtomicI32 = AtomicI32::new(1); - -fn next_request_id() -> i32 { - NEXT_REQUEST_ID.fetch_add(1, Ordering::Relaxed) -} - -#[derive(Debug, thiserror::Error)] -pub enum ConnectError { - #[error("Could not establish connection")] - Connection(#[from] tonic::transport::Error), - - #[error("Invalid server URI")] - InvalidUri(#[from] http::uri::InvalidUri), - - #[error("Invalid CA certificate")] - InvalidCaCert(#[from] webpki::Error), - - #[error("gRPC error")] - Grpc(#[from] tonic::Status), - - #[error("Auth challenge was not returned by server")] - MissingAuthChallenge, - - #[error("Client approval denied by User Agent")] - ApprovalDenied, - - #[error("No User Agents online to approve client")] - NoUserAgentsOnline, - - #[error("Unexpected auth response payload")] - UnexpectedAuthResponse, - - #[error("Signing key storage error")] - Storage(#[from] StorageError), -} - -#[derive(Debug, thiserror::Error)] -pub enum StorageError { - #[error("I/O error")] - Io(#[from] std::io::Error), - - #[error("Invalid signing key length in storage: expected {expected} bytes, got {actual} bytes")] - InvalidKeyLength { expected: usize, actual: usize }, -} - -pub trait SigningKeyStorage { - fn load_or_create(&self) -> std::result::Result; -} - -#[derive(Debug, Clone)] -pub struct FileSigningKeyStorage { - path: PathBuf, -} - -impl FileSigningKeyStorage { - pub const DEFAULT_FILE_NAME: &str = "sdk_client_ed25519.key"; - - pub fn new(path: impl Into) -> Self { - Self { path: path.into() } - } - - pub fn from_default_location() -> std::result::Result { - Ok(Self::new(home_path()?.join(Self::DEFAULT_FILE_NAME))) - } - - fn read_key(path: &Path) -> std::result::Result { - let bytes = std::fs::read(path)?; - let raw: [u8; 32] = - bytes - .try_into() - .map_err(|v: Vec| StorageError::InvalidKeyLength { - expected: 32, - actual: v.len(), - })?; - Ok(ed25519_dalek::SigningKey::from_bytes(&raw)) - } -} - -impl SigningKeyStorage for FileSigningKeyStorage { - fn load_or_create(&self) -> std::result::Result { - if let Some(parent) = self.path.parent() { - std::fs::create_dir_all(parent)?; - } - - if self.path.exists() { - return Self::read_key(&self.path); - } - - let key = ed25519_dalek::SigningKey::generate(&mut rand::rng()); - let raw_key = key.to_bytes(); - - // Use create_new to prevent accidental overwrite if another process creates the key first. - match std::fs::OpenOptions::new() - .create_new(true) - .write(true) - .open(&self.path) - { - Ok(mut file) => { - use std::io::Write as _; - file.write_all(&raw_key)?; - Ok(key) - } - Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => { - Self::read_key(&self.path) - } - Err(err) => Err(StorageError::Io(err)), - } - } -} - -#[derive(Debug, thiserror::Error)] -enum ClientSignError { - #[error("Transport channel closed")] - ChannelClosed, - - #[error("Connection closed by server")] - ConnectionClosed, - - #[error("Wallet address is not configured")] - WalletAddressNotConfigured, -} - -struct ClientTransport { - sender: mpsc::Sender, - receiver: tonic::Streaming, -} - -impl ClientTransport { - async fn send(&mut self, request: ClientRequest) -> std::result::Result<(), ClientSignError> { - self.sender - .send(request) - .await - .map_err(|_| ClientSignError::ChannelClosed) - } - - async fn recv(&mut self) -> std::result::Result { - match self.receiver.message().await { - Ok(Some(resp)) => Ok(resp), - Ok(None) => Err(ClientSignError::ConnectionClosed), - Err(_) => Err(ClientSignError::ConnectionClosed), - } - } -} - -pub struct ArbiterSigner { - transport: Mutex, - address: Option
, - chain_id: Option, -} - -impl ArbiterSigner { - pub async fn connect_grpc(url: ArbiterUrl) -> std::result::Result { - let storage = FileSigningKeyStorage::from_default_location()?; - Self::connect_grpc_with_storage(url, &storage).await - } - - pub async fn connect_grpc_with_storage( - url: ArbiterUrl, - storage: &S, - ) -> std::result::Result { - let key = storage.load_or_create()?; - Self::connect_grpc_with_key(url, key).await - } - - pub async fn connect_grpc_with_key( - url: ArbiterUrl, - key: ed25519_dalek::SigningKey, - ) -> std::result::Result { - let anchor = webpki::anchor_from_trusted_cert(&url.ca_cert)?.to_owned(); - let tls = ClientTlsConfig::new().trust_anchor(anchor); - - // NOTE: We intentionally keep the same URL construction strategy as the user-agent crate - // to avoid behavior drift between the two clients. - let channel = tonic::transport::Channel::from_shared(format!("{}:{}", url.host, url.port))? - .tls_config(tls)? - .connect() - .await?; - - let mut client = ArbiterServiceClient::new(channel); - let (tx, rx) = mpsc::channel(BUFFER_LENGTH); - let response_stream = client.client(ReceiverStream::new(rx)).await?.into_inner(); - - let mut transport = ClientTransport { - sender: tx, - receiver: response_stream, - }; - - authenticate(&mut transport, &key).await?; - - Ok(Self { - transport: Mutex::new(transport), - address: None, - chain_id: None, - }) - } - - pub fn wallet_address(&self) -> Option
{ - self.address - } - - pub fn set_wallet_address(&mut self, address: Option
) { - self.address = address; - } - - pub fn with_wallet_address(mut self, address: Address) -> Self { - self.address = Some(address); - self - } - - pub fn with_chain_id(mut self, chain_id: ChainId) -> Self { - self.chain_id = Some(chain_id); - self - } - - fn validate_chain_id(&self, tx: &mut dyn SignableTransaction) -> Result<()> { - if let Some(chain_id) = self.chain_id - && !tx.set_chain_id_checked(chain_id) - { - return Err(Error::TransactionChainIdMismatch { - signer: chain_id, - tx: tx.chain_id().unwrap(), - }); - } - - Ok(()) - } - - fn ensure_wallet_address(&self) -> Result
{ - let wallet_address = self - .address - .ok_or_else(|| Error::other(ClientSignError::WalletAddressNotConfigured))?; - - Ok(wallet_address) - } -} - -fn map_auth_result(code: i32) -> ConnectError { - match AuthResult::try_from(code).unwrap_or(AuthResult::Unspecified) { - AuthResult::ApprovalDenied => ConnectError::ApprovalDenied, - AuthResult::NoUserAgentsOnline => ConnectError::NoUserAgentsOnline, - AuthResult::Unspecified - | AuthResult::Success - | AuthResult::InvalidKey - | AuthResult::InvalidSignature - | AuthResult::Internal => ConnectError::UnexpectedAuthResponse, - } -} - -async fn send_auth_challenge_request( - transport: &mut ClientTransport, - key: &ed25519_dalek::SigningKey, -) -> std::result::Result<(), ConnectError> { - transport - .send(ClientRequest { - request_id: next_request_id(), - payload: Some(ClientRequestPayload::AuthChallengeRequest( - AuthChallengeRequest { - pubkey: key.verifying_key().to_bytes().to_vec(), - }, - )), - }) - .await - .map_err(|_| ConnectError::UnexpectedAuthResponse) -} - -async fn receive_auth_challenge( - transport: &mut ClientTransport, -) -> std::result::Result { - let response = transport - .recv() - .await - .map_err(|_| ConnectError::MissingAuthChallenge)?; - - let payload = response.payload.ok_or(ConnectError::MissingAuthChallenge)?; - match payload { - ClientResponsePayload::AuthChallenge(challenge) => Ok(challenge), - ClientResponsePayload::AuthResult(result) => Err(map_auth_result(result)), - _ => Err(ConnectError::UnexpectedAuthResponse), - } -} - -async fn send_auth_challenge_solution( - transport: &mut ClientTransport, - key: &ed25519_dalek::SigningKey, - challenge: arbiter_proto::proto::client::AuthChallenge, -) -> std::result::Result<(), ConnectError> { - let challenge_payload = format_challenge(challenge.nonce, &challenge.pubkey); - let signature = key.sign(&challenge_payload).to_bytes().to_vec(); - - transport - .send(ClientRequest { - request_id: next_request_id(), - payload: Some(ClientRequestPayload::AuthChallengeSolution( - AuthChallengeSolution { signature }, - )), - }) - .await - .map_err(|_| ConnectError::UnexpectedAuthResponse) -} - -async fn receive_auth_confirmation( - transport: &mut ClientTransport, -) -> std::result::Result<(), ConnectError> { - let response = transport - .recv() - .await - .map_err(|_| ConnectError::UnexpectedAuthResponse)?; - - let payload = response - .payload - .ok_or(ConnectError::UnexpectedAuthResponse)?; - match payload { - ClientResponsePayload::AuthResult(result) - if AuthResult::try_from(result).ok() == Some(AuthResult::Success) => - { - Ok(()) - } - ClientResponsePayload::AuthResult(result) => Err(map_auth_result(result)), - _ => Err(ConnectError::UnexpectedAuthResponse), - } -} - -async fn authenticate( - transport: &mut ClientTransport, - key: &ed25519_dalek::SigningKey, -) -> std::result::Result<(), ConnectError> { - send_auth_challenge_request(transport, key).await?; - let challenge = receive_auth_challenge(transport).await?; - send_auth_challenge_solution(transport, key, challenge).await?; - receive_auth_confirmation(transport).await -} - -#[async_trait] -impl Signer for ArbiterSigner { - async fn sign_hash(&self, _hash: &B256) -> Result { - Err(Error::other( - "hash-only signing is not supported for ArbiterSigner; use transaction signing", - )) - } - - fn address(&self) -> Address { - self.address.unwrap_or(Address::ZERO) - } - - fn chain_id(&self) -> Option { - self.chain_id - } - - fn set_chain_id(&mut self, chain_id: Option) { - self.chain_id = chain_id; - } -} - -#[async_trait] -impl TxSigner for ArbiterSigner { - fn address(&self) -> Address { - self.address.unwrap_or(Address::ZERO) - } - - async fn sign_transaction( - &self, - tx: &mut dyn SignableTransaction, - ) -> Result { - let _transport = self.transport.lock().await; - self.validate_chain_id(tx)?; - let _ = self.ensure_wallet_address()?; - - Err(Error::other( - "transaction signing is not supported by current arbiter.client protocol", - )) - } -} - -#[cfg(test)] -mod tests { - use super::{FileSigningKeyStorage, SigningKeyStorage, StorageError}; - - fn unique_temp_key_path() -> std::path::PathBuf { - let nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("clock should be after unix epoch") - .as_nanos(); - std::env::temp_dir().join(format!( - "arbiter-client-key-{}-{}.bin", - std::process::id(), - nanos - )) - } - - #[test] - fn file_storage_creates_and_reuses_key() { - let path = unique_temp_key_path(); - let storage = FileSigningKeyStorage::new(path.clone()); - - let key_a = storage - .load_or_create() - .expect("first load_or_create should create key"); - let key_b = storage - .load_or_create() - .expect("second load_or_create should read same key"); - - assert_eq!(key_a.to_bytes(), key_b.to_bytes()); - assert!(path.exists()); - - std::fs::remove_file(path).expect("temp key file should be removable"); - } - - #[test] - fn file_storage_rejects_invalid_key_length() { - let path = unique_temp_key_path(); - std::fs::write(&path, [42u8; 31]).expect("should write invalid key file"); - let storage = FileSigningKeyStorage::new(path.clone()); - - let err = storage - .load_or_create() - .expect_err("storage should reject non-32-byte key file"); - - match err { - StorageError::InvalidKeyLength { expected, actual } => { - assert_eq!(expected, 32); - assert_eq!(actual, 31); - } - other => panic!("unexpected error: {other:?}"), - } - - std::fs::remove_file(path).expect("temp key file should be removable"); - } -} +mod auth; +mod signer; +mod storage; +mod transport; + +pub use auth::ConnectError; +pub use signer::ArbiterSigner; +pub use storage::{FileSigningKeyStorage, SigningKeyStorage, StorageError}; diff --git a/server/crates/arbiter-client/src/signer.rs b/server/crates/arbiter-client/src/signer.rs new file mode 100644 index 0000000..3ef001a --- /dev/null +++ b/server/crates/arbiter-client/src/signer.rs @@ -0,0 +1,153 @@ +use alloy::{ + consensus::SignableTransaction, + network::TxSigner, + primitives::{Address, B256, ChainId, Signature}, + signers::{Error, Result, Signer}, +}; +use arbiter_proto::{ + proto::arbiter_service_client::ArbiterServiceClient, + url::ArbiterUrl, +}; +use async_trait::async_trait; +use tokio::sync::{Mutex, mpsc}; +use tokio_stream::wrappers::ReceiverStream; +use tonic::transport::ClientTlsConfig; + +use crate::{ + auth::{ConnectError, authenticate}, + storage::{FileSigningKeyStorage, SigningKeyStorage}, + transport::{BUFFER_LENGTH, ClientSignError, ClientTransport}, +}; + +pub struct ArbiterSigner { + transport: Mutex, + address: Option
, + chain_id: Option, +} + +impl ArbiterSigner { + pub async fn connect_grpc(url: ArbiterUrl) -> std::result::Result { + let storage = FileSigningKeyStorage::from_default_location()?; + Self::connect_grpc_with_storage(url, &storage).await + } + + pub async fn connect_grpc_with_storage( + url: ArbiterUrl, + storage: &S, + ) -> std::result::Result { + let key = storage.load_or_create()?; + Self::connect_grpc_with_key(url, key).await + } + + pub async fn connect_grpc_with_key( + url: ArbiterUrl, + key: ed25519_dalek::SigningKey, + ) -> std::result::Result { + let anchor = webpki::anchor_from_trusted_cert(&url.ca_cert)?.to_owned(); + let tls = ClientTlsConfig::new().trust_anchor(anchor); + + // NOTE: We intentionally keep the same URL construction strategy as the user-agent crate + // to avoid behavior drift between the two clients. + let channel = tonic::transport::Channel::from_shared(format!("{}:{}", url.host, url.port))? + .tls_config(tls)? + .connect() + .await?; + + let mut client = ArbiterServiceClient::new(channel); + let (tx, rx) = mpsc::channel(BUFFER_LENGTH); + let response_stream = client.client(ReceiverStream::new(rx)).await?.into_inner(); + + let mut transport = ClientTransport { + sender: tx, + receiver: response_stream, + }; + + authenticate(&mut transport, &key).await?; + + Ok(Self { + transport: Mutex::new(transport), + address: None, + chain_id: None, + }) + } + + pub fn wallet_address(&self) -> Option
{ + self.address + } + + pub fn set_wallet_address(&mut self, address: Option
) { + self.address = address; + } + + pub fn with_wallet_address(mut self, address: Address) -> Self { + self.address = Some(address); + self + } + + pub fn with_chain_id(mut self, chain_id: ChainId) -> Self { + self.chain_id = Some(chain_id); + self + } + + fn validate_chain_id(&self, tx: &mut dyn SignableTransaction) -> Result<()> { + if let Some(chain_id) = self.chain_id + && !tx.set_chain_id_checked(chain_id) + { + return Err(Error::TransactionChainIdMismatch { + signer: chain_id, + tx: tx.chain_id().unwrap(), + }); + } + + Ok(()) + } + + fn ensure_wallet_address(&self) -> Result
{ + let wallet_address = self + .address + .ok_or_else(|| Error::other(ClientSignError::WalletAddressNotConfigured))?; + + Ok(wallet_address) + } +} + +#[async_trait] +impl Signer for ArbiterSigner { + async fn sign_hash(&self, _hash: &B256) -> Result { + Err(Error::other( + "hash-only signing is not supported for ArbiterSigner; use transaction signing", + )) + } + + fn address(&self) -> Address { + self.address.unwrap_or(Address::ZERO) + } + + fn chain_id(&self) -> Option { + self.chain_id + } + + fn set_chain_id(&mut self, chain_id: Option) { + self.chain_id = chain_id; + } +} + +#[async_trait] +impl TxSigner for ArbiterSigner { + fn address(&self) -> Address { + self.address.unwrap_or(Address::ZERO) + } + + async fn sign_transaction( + &self, + tx: &mut dyn SignableTransaction, + ) -> Result { + let _transport = self.transport.lock().await; + self.validate_chain_id(tx)?; + let _ = self.ensure_wallet_address()?; + + Err(Error::other( + "transaction signing is not supported by current arbiter.client protocol", + )) + } +} diff --git a/server/crates/arbiter-client/src/storage.rs b/server/crates/arbiter-client/src/storage.rs new file mode 100644 index 0000000..17d0bf2 --- /dev/null +++ b/server/crates/arbiter-client/src/storage.rs @@ -0,0 +1,132 @@ +use arbiter_proto::home_path; +use std::path::{Path, PathBuf}; + +#[derive(Debug, thiserror::Error)] +pub enum StorageError { + #[error("I/O error")] + Io(#[from] std::io::Error), + + #[error("Invalid signing key length in storage: expected {expected} bytes, got {actual} bytes")] + InvalidKeyLength { expected: usize, actual: usize }, +} + +pub trait SigningKeyStorage { + fn load_or_create(&self) -> std::result::Result; +} + +#[derive(Debug, Clone)] +pub struct FileSigningKeyStorage { + path: PathBuf, +} + +impl FileSigningKeyStorage { + pub const DEFAULT_FILE_NAME: &str = "sdk_client_ed25519.key"; + + pub fn new(path: impl Into) -> Self { + Self { path: path.into() } + } + + pub fn from_default_location() -> std::result::Result { + Ok(Self::new(home_path()?.join(Self::DEFAULT_FILE_NAME))) + } + + fn read_key(path: &Path) -> std::result::Result { + let bytes = std::fs::read(path)?; + let raw: [u8; 32] = + bytes + .try_into() + .map_err(|v: Vec| StorageError::InvalidKeyLength { + expected: 32, + actual: v.len(), + })?; + Ok(ed25519_dalek::SigningKey::from_bytes(&raw)) + } +} + +impl SigningKeyStorage for FileSigningKeyStorage { + fn load_or_create(&self) -> std::result::Result { + if let Some(parent) = self.path.parent() { + std::fs::create_dir_all(parent)?; + } + + if self.path.exists() { + return Self::read_key(&self.path); + } + + let key = ed25519_dalek::SigningKey::generate(&mut rand::rng()); + let raw_key = key.to_bytes(); + + // Use create_new to prevent accidental overwrite if another process creates the key first. + match std::fs::OpenOptions::new() + .create_new(true) + .write(true) + .open(&self.path) + { + Ok(mut file) => { + use std::io::Write as _; + file.write_all(&raw_key)?; + Ok(key) + } + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => { + Self::read_key(&self.path) + } + Err(err) => Err(StorageError::Io(err)), + } + } +} + +#[cfg(test)] +mod tests { + use super::{FileSigningKeyStorage, SigningKeyStorage, StorageError}; + + fn unique_temp_key_path() -> std::path::PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock should be after unix epoch") + .as_nanos(); + std::env::temp_dir().join(format!( + "arbiter-client-key-{}-{}.bin", + std::process::id(), + nanos + )) + } + + #[test] + fn file_storage_creates_and_reuses_key() { + let path = unique_temp_key_path(); + let storage = FileSigningKeyStorage::new(path.clone()); + + let key_a = storage + .load_or_create() + .expect("first load_or_create should create key"); + let key_b = storage + .load_or_create() + .expect("second load_or_create should read same key"); + + assert_eq!(key_a.to_bytes(), key_b.to_bytes()); + assert!(path.exists()); + + std::fs::remove_file(path).expect("temp key file should be removable"); + } + + #[test] + fn file_storage_rejects_invalid_key_length() { + let path = unique_temp_key_path(); + std::fs::write(&path, [42u8; 31]).expect("should write invalid key file"); + let storage = FileSigningKeyStorage::new(path.clone()); + + let err = storage + .load_or_create() + .expect_err("storage should reject non-32-byte key file"); + + match err { + StorageError::InvalidKeyLength { expected, actual } => { + assert_eq!(expected, 32); + assert_eq!(actual, 31); + } + other => panic!("unexpected error: {other:?}"), + } + + std::fs::remove_file(path).expect("temp key file should be removable"); + } +} diff --git a/server/crates/arbiter-client/src/transport.rs b/server/crates/arbiter-client/src/transport.rs new file mode 100644 index 0000000..768d3e3 --- /dev/null +++ b/server/crates/arbiter-client/src/transport.rs @@ -0,0 +1,51 @@ +use arbiter_proto::proto::{ + client::{ClientRequest, ClientResponse}, +}; +use std::sync::atomic::{AtomicI32, Ordering}; +use tokio::sync::mpsc; + +pub(crate) const BUFFER_LENGTH: usize = 16; +static NEXT_REQUEST_ID: AtomicI32 = AtomicI32::new(1); + +pub(crate) fn next_request_id() -> i32 { + NEXT_REQUEST_ID.fetch_add(1, Ordering::Relaxed) +} + +#[derive(Debug, thiserror::Error)] +pub(crate) enum ClientSignError { + #[error("Transport channel closed")] + ChannelClosed, + + #[error("Connection closed by server")] + ConnectionClosed, + + #[error("Wallet address is not configured")] + WalletAddressNotConfigured, +} + +pub(crate) struct ClientTransport { + pub(crate) sender: mpsc::Sender, + pub(crate) receiver: tonic::Streaming, +} + +impl ClientTransport { + pub(crate) async fn send( + &mut self, + request: ClientRequest, + ) -> std::result::Result<(), ClientSignError> { + self.sender + .send(request) + .await + .map_err(|_| ClientSignError::ChannelClosed) + } + + 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), + Err(_) => Err(ClientSignError::ConnectionClosed), + } + } +} From eb37ee0a0ca7c29074f3741745961e891a17f77f Mon Sep 17 00:00:00 2001 From: hdbg Date: Sun, 22 Mar 2026 12:00:33 +0100 Subject: [PATCH 20/20] refactor(client): redesign of wallet handle --- server/crates/arbiter-client/Cargo.toml | 5 +- server/crates/arbiter-client/src/client.rs | 76 +++++++++ server/crates/arbiter-client/src/lib.rs | 8 +- server/crates/arbiter-client/src/signer.rs | 153 ------------------ server/crates/arbiter-client/src/transport.rs | 3 - .../crates/arbiter-client/src/wallets/evm.rs | 89 ++++++++++ .../crates/arbiter-client/src/wallets/mod.rs | 2 + 7 files changed, 177 insertions(+), 159 deletions(-) create mode 100644 server/crates/arbiter-client/src/client.rs delete mode 100644 server/crates/arbiter-client/src/signer.rs create mode 100644 server/crates/arbiter-client/src/wallets/evm.rs create mode 100644 server/crates/arbiter-client/src/wallets/mod.rs diff --git a/server/crates/arbiter-client/Cargo.toml b/server/crates/arbiter-client/Cargo.toml index 163c3f1..f5e353b 100644 --- a/server/crates/arbiter-client/Cargo.toml +++ b/server/crates/arbiter-client/Cargo.toml @@ -8,9 +8,12 @@ license = "Apache-2.0" [lints] workspace = true +[features] +evm = ["dep:alloy"] + [dependencies] arbiter-proto.path = "../arbiter-proto" -alloy.workspace = true +alloy = { workspace = true, optional = true } tonic.workspace = true tonic.features = ["tls-aws-lc"] tokio.workspace = true diff --git a/server/crates/arbiter-client/src/client.rs b/server/crates/arbiter-client/src/client.rs new file mode 100644 index 0000000..64d0d04 --- /dev/null +++ b/server/crates/arbiter-client/src/client.rs @@ -0,0 +1,76 @@ +use arbiter_proto::{proto::arbiter_service_client::ArbiterServiceClient, url::ArbiterUrl}; +use std::sync::Arc; +use tokio::sync::{Mutex, mpsc}; +use tokio_stream::wrappers::ReceiverStream; +use tonic::transport::ClientTlsConfig; + +use crate::{ + auth::{ConnectError, authenticate}, + storage::{FileSigningKeyStorage, SigningKeyStorage}, + transport::{BUFFER_LENGTH, ClientTransport}, +}; + +#[cfg(feature = "evm")] +use crate::wallets::evm::ArbiterEvmWallet; + +#[derive(Debug, thiserror::Error)] +pub enum ClientError { + #[error("gRPC error")] + Grpc(#[from] tonic::Status), + + #[error("Connection closed by server")] + ConnectionClosed, +} + +pub struct ArbiterClient { + #[allow(dead_code)] + transport: Arc>, +} + +impl ArbiterClient { + pub async fn connect(url: ArbiterUrl) -> Result { + let storage = FileSigningKeyStorage::from_default_location()?; + Self::connect_with_storage(url, &storage).await + } + + pub async fn connect_with_storage( + url: ArbiterUrl, + storage: &S, + ) -> Result { + let key = storage.load_or_create()?; + Self::connect_with_key(url, key).await + } + + pub async fn connect_with_key( + url: ArbiterUrl, + key: ed25519_dalek::SigningKey, + ) -> Result { + let anchor = webpki::anchor_from_trusted_cert(&url.ca_cert)?.to_owned(); + let tls = ClientTlsConfig::new().trust_anchor(anchor); + + let channel = tonic::transport::Channel::from_shared(format!("{}:{}", url.host, url.port))? + .tls_config(tls)? + .connect() + .await?; + + let mut client = ArbiterServiceClient::new(channel); + let (tx, rx) = mpsc::channel(BUFFER_LENGTH); + let response_stream = client.client(ReceiverStream::new(rx)).await?.into_inner(); + + let mut transport = ClientTransport { + sender: tx, + receiver: response_stream, + }; + + authenticate(&mut transport, &key).await?; + + Ok(Self { + transport: Arc::new(Mutex::new(transport)), + }) + } + + #[cfg(feature = "evm")] + pub async fn evm_wallets(&self) -> Result, ClientError> { + todo!("fetch EVM wallet list from server") + } +} diff --git a/server/crates/arbiter-client/src/lib.rs b/server/crates/arbiter-client/src/lib.rs index f98d107..1be4c38 100644 --- a/server/crates/arbiter-client/src/lib.rs +++ b/server/crates/arbiter-client/src/lib.rs @@ -1,8 +1,12 @@ mod auth; -mod signer; +mod client; mod storage; mod transport; +pub mod wallets; pub use auth::ConnectError; -pub use signer::ArbiterSigner; +pub use client::{ArbiterClient, ClientError}; pub use storage::{FileSigningKeyStorage, SigningKeyStorage, StorageError}; + +#[cfg(feature = "evm")] +pub use wallets::evm::ArbiterEvmWallet; diff --git a/server/crates/arbiter-client/src/signer.rs b/server/crates/arbiter-client/src/signer.rs deleted file mode 100644 index 3ef001a..0000000 --- a/server/crates/arbiter-client/src/signer.rs +++ /dev/null @@ -1,153 +0,0 @@ -use alloy::{ - consensus::SignableTransaction, - network::TxSigner, - primitives::{Address, B256, ChainId, Signature}, - signers::{Error, Result, Signer}, -}; -use arbiter_proto::{ - proto::arbiter_service_client::ArbiterServiceClient, - url::ArbiterUrl, -}; -use async_trait::async_trait; -use tokio::sync::{Mutex, mpsc}; -use tokio_stream::wrappers::ReceiverStream; -use tonic::transport::ClientTlsConfig; - -use crate::{ - auth::{ConnectError, authenticate}, - storage::{FileSigningKeyStorage, SigningKeyStorage}, - transport::{BUFFER_LENGTH, ClientSignError, ClientTransport}, -}; - -pub struct ArbiterSigner { - transport: Mutex, - address: Option
, - chain_id: Option, -} - -impl ArbiterSigner { - pub async fn connect_grpc(url: ArbiterUrl) -> std::result::Result { - let storage = FileSigningKeyStorage::from_default_location()?; - Self::connect_grpc_with_storage(url, &storage).await - } - - pub async fn connect_grpc_with_storage( - url: ArbiterUrl, - storage: &S, - ) -> std::result::Result { - let key = storage.load_or_create()?; - Self::connect_grpc_with_key(url, key).await - } - - pub async fn connect_grpc_with_key( - url: ArbiterUrl, - key: ed25519_dalek::SigningKey, - ) -> std::result::Result { - let anchor = webpki::anchor_from_trusted_cert(&url.ca_cert)?.to_owned(); - let tls = ClientTlsConfig::new().trust_anchor(anchor); - - // NOTE: We intentionally keep the same URL construction strategy as the user-agent crate - // to avoid behavior drift between the two clients. - let channel = tonic::transport::Channel::from_shared(format!("{}:{}", url.host, url.port))? - .tls_config(tls)? - .connect() - .await?; - - let mut client = ArbiterServiceClient::new(channel); - let (tx, rx) = mpsc::channel(BUFFER_LENGTH); - let response_stream = client.client(ReceiverStream::new(rx)).await?.into_inner(); - - let mut transport = ClientTransport { - sender: tx, - receiver: response_stream, - }; - - authenticate(&mut transport, &key).await?; - - Ok(Self { - transport: Mutex::new(transport), - address: None, - chain_id: None, - }) - } - - pub fn wallet_address(&self) -> Option
{ - self.address - } - - pub fn set_wallet_address(&mut self, address: Option
) { - self.address = address; - } - - pub fn with_wallet_address(mut self, address: Address) -> Self { - self.address = Some(address); - self - } - - pub fn with_chain_id(mut self, chain_id: ChainId) -> Self { - self.chain_id = Some(chain_id); - self - } - - fn validate_chain_id(&self, tx: &mut dyn SignableTransaction) -> Result<()> { - if let Some(chain_id) = self.chain_id - && !tx.set_chain_id_checked(chain_id) - { - return Err(Error::TransactionChainIdMismatch { - signer: chain_id, - tx: tx.chain_id().unwrap(), - }); - } - - Ok(()) - } - - fn ensure_wallet_address(&self) -> Result
{ - let wallet_address = self - .address - .ok_or_else(|| Error::other(ClientSignError::WalletAddressNotConfigured))?; - - Ok(wallet_address) - } -} - -#[async_trait] -impl Signer for ArbiterSigner { - async fn sign_hash(&self, _hash: &B256) -> Result { - Err(Error::other( - "hash-only signing is not supported for ArbiterSigner; use transaction signing", - )) - } - - fn address(&self) -> Address { - self.address.unwrap_or(Address::ZERO) - } - - fn chain_id(&self) -> Option { - self.chain_id - } - - fn set_chain_id(&mut self, chain_id: Option) { - self.chain_id = chain_id; - } -} - -#[async_trait] -impl TxSigner for ArbiterSigner { - fn address(&self) -> Address { - self.address.unwrap_or(Address::ZERO) - } - - async fn sign_transaction( - &self, - tx: &mut dyn SignableTransaction, - ) -> Result { - let _transport = self.transport.lock().await; - self.validate_chain_id(tx)?; - let _ = self.ensure_wallet_address()?; - - Err(Error::other( - "transaction signing is not supported by current arbiter.client protocol", - )) - } -} diff --git a/server/crates/arbiter-client/src/transport.rs b/server/crates/arbiter-client/src/transport.rs index 768d3e3..d56a9f8 100644 --- a/server/crates/arbiter-client/src/transport.rs +++ b/server/crates/arbiter-client/src/transport.rs @@ -18,9 +18,6 @@ pub(crate) enum ClientSignError { #[error("Connection closed by server")] ConnectionClosed, - - #[error("Wallet address is not configured")] - WalletAddressNotConfigured, } pub(crate) struct ClientTransport { diff --git a/server/crates/arbiter-client/src/wallets/evm.rs b/server/crates/arbiter-client/src/wallets/evm.rs new file mode 100644 index 0000000..32ae735 --- /dev/null +++ b/server/crates/arbiter-client/src/wallets/evm.rs @@ -0,0 +1,89 @@ +use alloy::{ + consensus::SignableTransaction, + network::TxSigner, + primitives::{Address, B256, ChainId, Signature}, + signers::{Error, Result, Signer}, +}; +use async_trait::async_trait; +use std::sync::Arc; +use tokio::sync::Mutex; + +use crate::transport::ClientTransport; + +pub struct ArbiterEvmWallet { + transport: Arc>, + address: Address, + chain_id: Option, +} + +impl ArbiterEvmWallet { + pub(crate) fn new(transport: Arc>, address: Address) -> Self { + Self { + transport, + address, + chain_id: None, + } + } + + pub fn address(&self) -> Address { + self.address + } + + pub fn with_chain_id(mut self, chain_id: ChainId) -> Self { + self.chain_id = Some(chain_id); + self + } + + fn validate_chain_id(&self, tx: &mut dyn SignableTransaction) -> Result<()> { + if let Some(chain_id) = self.chain_id + && !tx.set_chain_id_checked(chain_id) + { + return Err(Error::TransactionChainIdMismatch { + signer: chain_id, + tx: tx.chain_id().unwrap(), + }); + } + + Ok(()) + } +} + +#[async_trait] +impl Signer for ArbiterEvmWallet { + async fn sign_hash(&self, _hash: &B256) -> Result { + Err(Error::other( + "hash-only signing is not supported for ArbiterEvmWallet; use transaction signing", + )) + } + + fn address(&self) -> Address { + self.address + } + + fn chain_id(&self) -> Option { + self.chain_id + } + + fn set_chain_id(&mut self, chain_id: Option) { + self.chain_id = chain_id; + } +} + +#[async_trait] +impl TxSigner for ArbiterEvmWallet { + fn address(&self) -> Address { + self.address + } + + async fn sign_transaction( + &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", + )) + } +} diff --git a/server/crates/arbiter-client/src/wallets/mod.rs b/server/crates/arbiter-client/src/wallets/mod.rs new file mode 100644 index 0000000..b2c917e --- /dev/null +++ b/server/crates/arbiter-client/src/wallets/mod.rs @@ -0,0 +1,2 @@ +#[cfg(feature = "evm")] +pub mod evm;