feat(poc): add terrors PoC crate scaffold and error types

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
CleverWild
2026-03-15 19:21:55 +01:00
parent 84978afd58
commit 02980468db
18 changed files with 1144 additions and 283 deletions

View File

@@ -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<ClientRequest>,
receiver: tonic::Streaming<ClientResponse>,
}
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<ClientResponse, ClientSignError> {
match self.receiver.message().await {
Ok(Some(resp)) => Ok(resp),
Ok(None) => Err(ClientSignError::ConnectionClosed),
Err(_) => Err(ClientSignError::ConnectionClosed),
}
}
}
pub struct ArbiterSigner {
transport: Mutex<ClientTransport>,
address: Address,
chain_id: Option<ChainId>,
}
impl ArbiterSigner {
pub async fn connect_grpc(
url: ArbiterUrl,
key: ed25519_dalek::SigningKey,
address: Address,
) -> std::result::Result<Self, ConnectError> {
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<Signature>,
) -> Result<Signature> {
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<Signature> {
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<ChainId> {
self.chain_id
}
fn set_chain_id(&mut self, chain_id: Option<ChainId>) {
self.chain_id = chain_id;
}
}
#[async_trait]
impl TxSigner<Signature> for ArbiterSigner {
fn address(&self) -> Address {
self.address
}
async fn sign_transaction(
&self,
tx: &mut dyn SignableTransaction<Signature>,
) -> Result<Signature> {
self.sign_transaction_via_arbiter(tx).await
}
}