refactor(arbiter-client): rewrite errors to terrros

This commit is contained in:
CleverWild
2026-03-24 17:25:45 +01:00
parent 2148faa376
commit efb11d2271
10 changed files with 235 additions and 132 deletions

6
server/Cargo.lock generated
View File

@@ -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"

View File

@@ -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" }

View File

@@ -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

View File

@@ -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)),
}
}

View File

@@ -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<Mutex<ClientTransport>>,
@@ -29,7 +25,7 @@ pub struct ArbiterClient {
impl ArbiterClient {
pub async fn connect(url: ArbiterUrl) -> Result<Self, ConnectError> {
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<Self, ConnectError> {
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<Self, ConnectError> {
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<Vec<ArbiterEvmWallet>, ClientError> {
todo!("fetch EVM wallet list from server")
let _ = &self.transport;
Err(OneOf::new(ClientConnectionClosedError))
}
}

View File

@@ -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<EvmChainIdMismatchError> for AlloySignerError {
fn from(value: EvmChainIdMismatchError) -> Self {
AlloySignerError::TransactionChainIdMismatch {
signer: value.signer,
tx: value.tx,
}
}
}
#[cfg(feature = "evm")]
impl From<EvmHashSigningUnsupportedError> 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<EvmTransactionSigningUnsupportedError> for AlloySignerError {
fn from(_value: EvmTransactionSigningUnsupportedError) -> Self {
AlloySignerError::other(
"transaction signing is not supported by current arbiter.client protocol",
)
}
}

View File

@@ -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;

View File

@@ -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<ed25519_dalek::SigningKey, StorageError>;
@@ -27,17 +21,20 @@ impl FileSigningKeyStorage {
}
pub fn from_default_location() -> std::result::Result<Self, StorageError> {
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<ed25519_dalek::SigningKey, StorageError> {
let bytes = std::fs::read(path)?;
let raw: [u8; 32] =
bytes
.try_into()
.map_err(|v: Vec<u8>| StorageError::InvalidKeyLength {
let bytes = std::fs::read(path).map_err(OneOf::new)?;
let raw: [u8; 32] = bytes.try_into().map_err(|v: Vec<u8>| {
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<ed25519_dalek::SigningKey, StorageError> {
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::<InvalidKeyLengthError, _>() {
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");

View File

@@ -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<ClientRequest>,
pub(crate) receiver: tonic::Streaming<ClientResponse>,
@@ -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<ClientResponse, ClientSignError> {
) -> std::result::Result<ClientResponse, ClientTransportError> {
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)),
}
}
}

View File

@@ -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<Mutex<ClientTransport>>,
@@ -17,6 +24,7 @@ pub struct ArbiterEvmWallet {
}
impl ArbiterEvmWallet {
#[allow(dead_code)]
pub(crate) fn new(transport: Arc<Mutex<ClientTransport>>, address: Address) -> Self {
Self {
transport,
@@ -34,14 +42,17 @@ impl ArbiterEvmWallet {
self
}
fn validate_chain_id(&self, tx: &mut dyn SignableTransaction<Signature>) -> Result<()> {
fn validate_chain_id(
&self,
tx: &mut dyn SignableTransaction<Signature>,
) -> 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<Signature> {
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<Signature> for ArbiterEvmWallet {
tx: &mut dyn SignableTransaction<Signature>,
) -> Result<Signature> {
let _transport = self.transport.lock().await;
self.validate_chain_id(tx)?;
self.validate_chain_id(tx)
.map_err(OneOf::into::<alloy::signers::Error>)?;
Err(Error::other(
"transaction signing is not supported by current arbiter.client protocol",
))
Err(EvmWalletError::new(EvmTransactionSigningUnsupportedError).into())
}
}