diff --git a/server/Cargo.lock b/server/Cargo.lock index 32a1587..0a7de08 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -686,6 +686,7 @@ dependencies = [ "http", "rand 0.10.0", "rustls-webpki", + "terrors", "thiserror 2.0.18", "tokio", "tokio-stream", @@ -4908,6 +4909,11 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "terrors" +version = "0.5.1" +source = "git+https://github.com/CleverWild/terrors#a0867fd9ca3fbb44c32e92113a917f1577b5716a" + [[package]] name = "test-log" version = "0.2.19" diff --git a/server/Cargo.toml b/server/Cargo.toml index ddc9416..ea5245f 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -1,7 +1,5 @@ [workspace] -members = [ - "crates/*", -] +members = ["crates/*"] resolver = "3" [workspace.lints.clippy] @@ -43,3 +41,4 @@ k256 = { version = "0.13.4", features = ["ecdsa", "pkcs8"] } rsa = { version = "0.9", features = ["sha2"] } sha2 = "0.10" spki = "0.7" +terrors = { version = "0.5", git = "https://github.com/CleverWild/terrors" } diff --git a/server/crates/arbiter-client/Cargo.toml b/server/crates/arbiter-client/Cargo.toml index f5e353b..8bb9583 100644 --- a/server/crates/arbiter-client/Cargo.toml +++ b/server/crates/arbiter-client/Cargo.toml @@ -24,3 +24,4 @@ http = "1.4.0" rustls-webpki = { version = "0.103.10", features = ["aws-lc-rs"] } async-trait.workspace = true rand.workspace = true +terrors.workspace = true \ No newline at end of file diff --git a/server/crates/arbiter-client/src/auth.rs b/server/crates/arbiter-client/src/auth.rs index 349c148..5bc3378 100644 --- a/server/crates/arbiter-client/src/auth.rs +++ b/server/crates/arbiter-client/src/auth.rs @@ -7,54 +7,15 @@ use arbiter_proto::{ }, }; use ed25519_dalek::Signer as _; +use terrors::OneOf; use crate::{ - storage::StorageError, + errors::{ + ConnectError, MissingAuthChallengeError, UnexpectedAuthResponseError, map_auth_code_error, + }, 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, @@ -69,7 +30,7 @@ async fn send_auth_challenge_request( )), }) .await - .map_err(|_| ConnectError::UnexpectedAuthResponse) + .map_err(|_| OneOf::new(UnexpectedAuthResponseError)) } async fn receive_auth_challenge( @@ -78,13 +39,15 @@ async fn receive_auth_challenge( let response = transport .recv() .await - .map_err(|_| ConnectError::MissingAuthChallenge)?; + .map_err(|_| OneOf::new(MissingAuthChallengeError))?; - let payload = response.payload.ok_or(ConnectError::MissingAuthChallenge)?; + let payload = response + .payload + .ok_or_else(|| OneOf::new(MissingAuthChallengeError))?; match payload { ClientResponsePayload::AuthChallenge(challenge) => Ok(challenge), - ClientResponsePayload::AuthResult(result) => Err(map_auth_result(result)), - _ => Err(ConnectError::UnexpectedAuthResponse), + ClientResponsePayload::AuthResult(result) => Err(map_auth_code_error(result)), + _ => Err(OneOf::new(UnexpectedAuthResponseError)), } } @@ -104,7 +67,7 @@ async fn send_auth_challenge_solution( )), }) .await - .map_err(|_| ConnectError::UnexpectedAuthResponse) + .map_err(|_| OneOf::new(UnexpectedAuthResponseError)) } async fn receive_auth_confirmation( @@ -113,19 +76,19 @@ async fn receive_auth_confirmation( let response = transport .recv() .await - .map_err(|_| ConnectError::UnexpectedAuthResponse)?; + .map_err(|_| OneOf::new(UnexpectedAuthResponseError))?; let payload = response .payload - .ok_or(ConnectError::UnexpectedAuthResponse)?; + .ok_or_else(|| OneOf::new(UnexpectedAuthResponseError))?; 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), + ClientResponsePayload::AuthResult(result) => Err(map_auth_code_error(result)), + _ => Err(OneOf::new(UnexpectedAuthResponseError)), } } diff --git a/server/crates/arbiter-client/src/client.rs b/server/crates/arbiter-client/src/client.rs index 64d0d04..570d5f8 100644 --- a/server/crates/arbiter-client/src/client.rs +++ b/server/crates/arbiter-client/src/client.rs @@ -1,27 +1,23 @@ use arbiter_proto::{proto::arbiter_service_client::ArbiterServiceClient, url::ArbiterUrl}; use std::sync::Arc; +use terrors::{Broaden as _, OneOf}; use tokio::sync::{Mutex, mpsc}; use tokio_stream::wrappers::ReceiverStream; use tonic::transport::ClientTlsConfig; use crate::{ - auth::{ConnectError, authenticate}, + auth::authenticate, + errors::ConnectError, storage::{FileSigningKeyStorage, SigningKeyStorage}, transport::{BUFFER_LENGTH, ClientTransport}, }; +#[cfg(feature = "evm")] +use crate::errors::{ClientConnectionClosedError, ClientError}; + #[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>, @@ -29,7 +25,7 @@ pub struct ArbiterClient { impl ArbiterClient { pub async fn connect(url: ArbiterUrl) -> Result { - let storage = FileSigningKeyStorage::from_default_location()?; + let storage = FileSigningKeyStorage::from_default_location().broaden()?; Self::connect_with_storage(url, &storage).await } @@ -37,7 +33,7 @@ impl ArbiterClient { url: ArbiterUrl, storage: &S, ) -> Result { - let key = storage.load_or_create()?; + let key = storage.load_or_create().broaden()?; Self::connect_with_key(url, key).await } @@ -45,17 +41,26 @@ impl ArbiterClient { url: ArbiterUrl, key: ed25519_dalek::SigningKey, ) -> Result { - let anchor = webpki::anchor_from_trusted_cert(&url.ca_cert)?.to_owned(); + let anchor = webpki::anchor_from_trusted_cert(&url.ca_cert) + .map_err(OneOf::new)? + .to_owned(); let tls = ClientTlsConfig::new().trust_anchor(anchor); - let channel = tonic::transport::Channel::from_shared(format!("{}:{}", url.host, url.port))? - .tls_config(tls)? + let channel = tonic::transport::Channel::from_shared(format!("{}:{}", url.host, url.port)) + .map_err(OneOf::new)? + .tls_config(tls) + .map_err(OneOf::new)? .connect() - .await?; + .await + .map_err(OneOf::new)?; 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 response_stream = client + .client(ReceiverStream::new(rx)) + .await + .map_err(OneOf::new)? + .into_inner(); let mut transport = ClientTransport { sender: tx, @@ -71,6 +76,7 @@ impl ArbiterClient { #[cfg(feature = "evm")] pub async fn evm_wallets(&self) -> Result, ClientError> { - todo!("fetch EVM wallet list from server") + let _ = &self.transport; + Err(OneOf::new(ClientConnectionClosedError)) } } diff --git a/server/crates/arbiter-client/src/errors.rs b/server/crates/arbiter-client/src/errors.rs new file mode 100644 index 0000000..8b9a709 --- /dev/null +++ b/server/crates/arbiter-client/src/errors.rs @@ -0,0 +1,127 @@ +use terrors::OneOf; +use thiserror::Error; + +#[cfg(feature = "evm")] +use alloy::{primitives::ChainId, signers::Error as AlloySignerError}; + +pub type StorageError = OneOf<(std::io::Error, InvalidKeyLengthError)>; + +pub type ConnectError = OneOf<( + tonic::transport::Error, + http::uri::InvalidUri, + webpki::Error, + tonic::Status, + MissingAuthChallengeError, + ApprovalDeniedError, + NoUserAgentsOnlineError, + UnexpectedAuthResponseError, + std::io::Error, + InvalidKeyLengthError, +)>; + +pub type ClientError = OneOf<(tonic::Status, ClientConnectionClosedError)>; + +pub(crate) type ClientTransportError = + OneOf<(TransportChannelClosedError, TransportConnectionClosedError)>; + +#[cfg(feature = "evm")] +pub(crate) type EvmWalletError = OneOf<( + EvmChainIdMismatchError, + EvmHashSigningUnsupportedError, + EvmTransactionSigningUnsupportedError, +)>; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +#[error("Invalid signing key length in storage: expected {expected} bytes, got {actual} bytes")] +pub struct InvalidKeyLengthError { + pub expected: usize, + pub actual: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +#[error("Auth challenge was not returned by server")] +pub struct MissingAuthChallengeError; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +#[error("Client approval denied by User Agent")] +pub struct ApprovalDeniedError; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +#[error("No User Agents online to approve client")] +pub struct NoUserAgentsOnlineError; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +#[error("Unexpected auth response payload")] +pub struct UnexpectedAuthResponseError; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +#[error("Connection closed by server")] +pub struct ClientConnectionClosedError; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +#[error("Transport channel closed")] +pub struct TransportChannelClosedError; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +#[error("Connection closed by server")] +pub struct TransportConnectionClosedError; + +#[cfg(feature = "evm")] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +#[error("Transaction chain id mismatch: signer {signer}, tx {tx}")] +pub struct EvmChainIdMismatchError { + pub signer: ChainId, + pub tx: ChainId, +} + +#[cfg(feature = "evm")] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +#[error("hash-only signing is not supported for ArbiterEvmWallet; use transaction signing")] +pub struct EvmHashSigningUnsupportedError; + +#[cfg(feature = "evm")] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +#[error("transaction signing is not supported by current arbiter.client protocol")] +pub struct EvmTransactionSigningUnsupportedError; + +pub(crate) fn map_auth_code_error(code: i32) -> ConnectError { + use arbiter_proto::proto::client::AuthResult; + + match AuthResult::try_from(code).unwrap_or(AuthResult::Unspecified) { + AuthResult::ApprovalDenied => OneOf::new(ApprovalDeniedError), + AuthResult::NoUserAgentsOnline => OneOf::new(NoUserAgentsOnlineError), + AuthResult::Unspecified + | AuthResult::Success + | AuthResult::InvalidKey + | AuthResult::InvalidSignature + | AuthResult::Internal => OneOf::new(UnexpectedAuthResponseError), + } +} + +#[cfg(feature = "evm")] +impl From for AlloySignerError { + fn from(value: EvmChainIdMismatchError) -> Self { + AlloySignerError::TransactionChainIdMismatch { + signer: value.signer, + tx: value.tx, + } + } +} + +#[cfg(feature = "evm")] +impl From for AlloySignerError { + fn from(_value: EvmHashSigningUnsupportedError) -> Self { + AlloySignerError::other( + "hash-only signing is not supported for ArbiterEvmWallet; use transaction signing", + ) + } +} + +#[cfg(feature = "evm")] +impl From for AlloySignerError { + fn from(_value: EvmTransactionSigningUnsupportedError) -> Self { + AlloySignerError::other( + "transaction signing is not supported by current arbiter.client protocol", + ) + } +} diff --git a/server/crates/arbiter-client/src/lib.rs b/server/crates/arbiter-client/src/lib.rs index 1be4c38..a8d3b5e 100644 --- a/server/crates/arbiter-client/src/lib.rs +++ b/server/crates/arbiter-client/src/lib.rs @@ -1,12 +1,13 @@ mod auth; mod client; +mod errors; mod storage; mod transport; pub mod wallets; -pub use auth::ConnectError; -pub use client::{ArbiterClient, ClientError}; -pub use storage::{FileSigningKeyStorage, SigningKeyStorage, StorageError}; +pub use client::ArbiterClient; +pub use errors::{ClientError, ConnectError, StorageError}; +pub use storage::{FileSigningKeyStorage, SigningKeyStorage}; #[cfg(feature = "evm")] pub use wallets::evm::ArbiterEvmWallet; diff --git a/server/crates/arbiter-client/src/storage.rs b/server/crates/arbiter-client/src/storage.rs index 17d0bf2..7812bd2 100644 --- a/server/crates/arbiter-client/src/storage.rs +++ b/server/crates/arbiter-client/src/storage.rs @@ -1,14 +1,8 @@ use arbiter_proto::home_path; use std::path::{Path, PathBuf}; +use terrors::OneOf; -#[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 }, -} +use crate::errors::{InvalidKeyLengthError, StorageError}; pub trait SigningKeyStorage { fn load_or_create(&self) -> std::result::Result; @@ -27,18 +21,21 @@ impl FileSigningKeyStorage { } pub fn from_default_location() -> std::result::Result { - Ok(Self::new(home_path()?.join(Self::DEFAULT_FILE_NAME))) + Ok(Self::new( + home_path() + .map_err(OneOf::new)? + .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(), - })?; + let bytes = std::fs::read(path).map_err(OneOf::new)?; + let raw: [u8; 32] = bytes.try_into().map_err(|v: Vec| { + OneOf::new(InvalidKeyLengthError { + expected: 32, + actual: v.len(), + }) + })?; Ok(ed25519_dalek::SigningKey::from_bytes(&raw)) } } @@ -46,7 +43,7 @@ impl FileSigningKeyStorage { 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)?; + std::fs::create_dir_all(parent).map_err(OneOf::new)?; } if self.path.exists() { @@ -64,20 +61,21 @@ impl SigningKeyStorage for FileSigningKeyStorage { { Ok(mut file) => { use std::io::Write as _; - file.write_all(&raw_key)?; + file.write_all(&raw_key).map_err(OneOf::new)?; Ok(key) } Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => { Self::read_key(&self.path) } - Err(err) => Err(StorageError::Io(err)), + Err(err) => Err(OneOf::new(err)), } } } #[cfg(test)] mod tests { - use super::{FileSigningKeyStorage, SigningKeyStorage, StorageError}; + use super::{FileSigningKeyStorage, SigningKeyStorage}; + use crate::errors::InvalidKeyLengthError; fn unique_temp_key_path() -> std::path::PathBuf { let nanos = std::time::SystemTime::now() @@ -119,12 +117,12 @@ mod tests { .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); + match err.narrow::() { + Ok(invalid_len) => { + assert_eq!(invalid_len.expected, 32); + assert_eq!(invalid_len.actual, 31); } - other => panic!("unexpected error: {other:?}"), + Err(other) => panic!("unexpected io 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 index d56a9f8..6ffa627 100644 --- a/server/crates/arbiter-client/src/transport.rs +++ b/server/crates/arbiter-client/src/transport.rs @@ -1,9 +1,12 @@ -use arbiter_proto::proto::{ - client::{ClientRequest, ClientResponse}, -}; +use arbiter_proto::proto::client::{ClientRequest, ClientResponse}; use std::sync::atomic::{AtomicI32, Ordering}; +use terrors::OneOf; use tokio::sync::mpsc; +use crate::errors::{ + ClientTransportError, TransportChannelClosedError, TransportConnectionClosedError, +}; + pub(crate) const BUFFER_LENGTH: usize = 16; static NEXT_REQUEST_ID: AtomicI32 = AtomicI32::new(1); @@ -11,15 +14,6 @@ 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, -} - pub(crate) struct ClientTransport { pub(crate) sender: mpsc::Sender, pub(crate) receiver: tonic::Streaming, @@ -29,20 +23,20 @@ impl ClientTransport { pub(crate) async fn send( &mut self, request: ClientRequest, - ) -> std::result::Result<(), ClientSignError> { + ) -> std::result::Result<(), ClientTransportError> { self.sender .send(request) .await - .map_err(|_| ClientSignError::ChannelClosed) + .map_err(|_| OneOf::new(TransportChannelClosedError)) } pub(crate) async fn recv( &mut self, - ) -> std::result::Result { + ) -> std::result::Result { match self.receiver.message().await { Ok(Some(resp)) => Ok(resp), - Ok(None) => Err(ClientSignError::ConnectionClosed), - Err(_) => Err(ClientSignError::ConnectionClosed), + Ok(None) => Err(OneOf::new(TransportConnectionClosedError)), + Err(_) => Err(OneOf::new(TransportConnectionClosedError)), } } } diff --git a/server/crates/arbiter-client/src/wallets/evm.rs b/server/crates/arbiter-client/src/wallets/evm.rs index 32ae735..44912f7 100644 --- a/server/crates/arbiter-client/src/wallets/evm.rs +++ b/server/crates/arbiter-client/src/wallets/evm.rs @@ -2,13 +2,20 @@ use alloy::{ consensus::SignableTransaction, network::TxSigner, primitives::{Address, B256, ChainId, Signature}, - signers::{Error, Result, Signer}, + signers::{Result, Signer}, }; use async_trait::async_trait; use std::sync::Arc; +use terrors::OneOf; use tokio::sync::Mutex; -use crate::transport::ClientTransport; +use crate::{ + errors::{ + EvmChainIdMismatchError, EvmHashSigningUnsupportedError, + EvmTransactionSigningUnsupportedError, EvmWalletError, + }, + transport::ClientTransport, +}; pub struct ArbiterEvmWallet { transport: Arc>, @@ -17,6 +24,7 @@ pub struct ArbiterEvmWallet { } impl ArbiterEvmWallet { + #[allow(dead_code)] pub(crate) fn new(transport: Arc>, address: Address) -> Self { Self { transport, @@ -34,14 +42,17 @@ impl ArbiterEvmWallet { self } - fn validate_chain_id(&self, tx: &mut dyn SignableTransaction) -> Result<()> { + fn validate_chain_id( + &self, + tx: &mut dyn SignableTransaction, + ) -> std::result::Result<(), EvmWalletError> { if let Some(chain_id) = self.chain_id && !tx.set_chain_id_checked(chain_id) { - return Err(Error::TransactionChainIdMismatch { + return Err(OneOf::new(EvmChainIdMismatchError { signer: chain_id, tx: tx.chain_id().unwrap(), - }); + })); } Ok(()) @@ -51,9 +62,7 @@ impl ArbiterEvmWallet { #[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", - )) + Err(EvmWalletError::new(EvmHashSigningUnsupportedError).into()) } fn address(&self) -> Address { @@ -80,10 +89,9 @@ impl TxSigner for ArbiterEvmWallet { tx: &mut dyn SignableTransaction, ) -> Result { let _transport = self.transport.lock().await; - self.validate_chain_id(tx)?; + self.validate_chain_id(tx) + .map_err(OneOf::into::)?; - Err(Error::other( - "transaction signing is not supported by current arbiter.client protocol", - )) + Err(EvmWalletError::new(EvmTransactionSigningUnsupportedError).into()) } }