use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::time::Duration;
use crate::errors::AppError;
pub use super::sidecar_types::{
BalanceRequest, BalanceResponse, BatchSwapRequest, BatchSwapResponse, DepositRequest,
DepositResponse, HealthResponse, SidecarClientConfig, SwapAndDepositRequest,
SwapAndDepositResponse, TransferResponse, TransferSolRequest, TransferSplRequest,
VerifySolTransferRequest, VerifySolTransferResponse, WalletBalancesRequest,
WalletBalancesResponse, WithdrawRequest, WithdrawResponse,
};
use super::sidecar_types::{ensure_sidecar_success, ErrorResponse, SidecarSuccess};
const DEFAULT_MAX_RETRIES: u32 = 3;
const DEFAULT_INITIAL_DELAY_MS: u64 = 500;
const DEFAULT_MAX_DELAY_MS: u64 = 4000;
pub struct PrivacySidecarClient {
client: Client,
base_url: String,
api_key: String,
}
impl PrivacySidecarClient {
pub fn new(config: SidecarClientConfig) -> Result<Self, AppError> {
let client = Client::builder()
.timeout(Duration::from_millis(config.timeout_ms))
.build()
.map_err(|e| AppError::Internal(e.into()))?;
Ok(Self {
client,
base_url: config.base_url.trim_end_matches('/').to_string(),
api_key: config.api_key,
})
}
async fn post_with_retry<Req: Serialize>(
&self,
url: &str,
body: &Req,
) -> Result<reqwest::Response, AppError> {
let mut last_error = None;
let mut delay_ms = DEFAULT_INITIAL_DELAY_MS;
for attempt in 0..=DEFAULT_MAX_RETRIES {
match self
.client
.post(url)
.header("Authorization", format!("Bearer {}", self.api_key))
.json(body)
.send()
.await
{
Ok(response) => return Ok(response),
Err(e) => {
if e.is_timeout() || e.is_connect() || e.is_request() {
tracing::warn!(
attempt = attempt + 1,
max_retries = DEFAULT_MAX_RETRIES,
error = %e,
url = %url,
"Sidecar request failed, will retry"
);
last_error = Some(e);
if attempt < DEFAULT_MAX_RETRIES {
tokio::time::sleep(Duration::from_millis(delay_ms)).await;
delay_ms = (delay_ms * 2).min(DEFAULT_MAX_DELAY_MS);
}
} else {
return Err(AppError::Internal(e.into()));
}
}
}
}
tracing::error!(
url = %url,
max_retries = DEFAULT_MAX_RETRIES,
"Sidecar request failed after all retries"
);
Err(AppError::Internal(
last_error
.map(|e| e.into())
.unwrap_or_else(|| anyhow::anyhow!("Sidecar request failed after retries")),
))
}
pub async fn health(&self) -> Result<HealthResponse, AppError> {
let url = format!("{}/health", self.base_url);
let response = self
.client
.get(&url)
.send()
.await
.map_err(|e| AppError::Internal(e.into()))?;
if !response.status().is_success() {
return Err(AppError::Internal(anyhow::anyhow!(
"Sidecar health check failed: {}",
response.status()
)));
}
response
.json()
.await
.map_err(|e| AppError::Internal(e.into()))
}
pub async fn deposit(
&self,
user_private_key: &str,
amount_lamports: u64,
) -> Result<DepositResponse, AppError> {
let url = format!("{}/deposit", self.base_url);
let body = DepositRequest {
user_private_key: user_private_key.to_string(),
amount_lamports,
};
let response = self.post_with_retry(&url, &body).await?;
self.handle_success_response(response).await
}
pub async fn withdraw(
&self,
user_private_key: &str,
amount_lamports: u64,
target_currency: Option<&str>,
) -> Result<WithdrawResponse, AppError> {
let url = format!("{}/withdraw", self.base_url);
let body = WithdrawRequest {
user_private_key: user_private_key.to_string(),
amount_lamports,
target_currency: target_currency.map(String::from),
};
let response = self.post_with_retry(&url, &body).await?;
self.handle_success_response(response).await
}
pub async fn swap_and_deposit(
&self,
user_private_key: &str,
input_mint: &str,
amount: &str,
) -> Result<SwapAndDepositResponse, AppError> {
let url = format!("{}/deposit/swap-and-deposit", self.base_url);
let body = SwapAndDepositRequest {
user_private_key: user_private_key.to_string(),
input_mint: input_mint.to_string(),
amount: amount.to_string(),
};
let response = self.post_with_retry(&url, &body).await?;
self.handle_success_response(response).await
}
pub async fn get_balance(&self, user_private_key: &str) -> Result<BalanceResponse, AppError> {
let url = format!("{}/withdraw/balance", self.base_url);
let response = self
.client
.post(&url)
.header("Authorization", format!("Bearer {}", self.api_key))
.json(&BalanceRequest {
user_private_key: user_private_key.to_string(),
})
.send()
.await
.map_err(|e| AppError::Internal(e.into()))?;
self.handle_response(response).await
}
pub async fn batch_swap(
&self,
treasury_private_key: &str,
amount_lamports: u64,
output_currency: &str,
) -> Result<BatchSwapResponse, AppError> {
let url = format!("{}/batch/swap", self.base_url);
let body = BatchSwapRequest {
private_key: treasury_private_key.to_string(),
amount_lamports,
output_currency: output_currency.to_string(),
};
let response = self.post_with_retry(&url, &body).await?;
self.handle_success_response(response).await
}
pub async fn transfer_sol(
&self,
user_private_key: &str,
destination: &str,
amount_lamports: u64,
) -> Result<TransferResponse, AppError> {
let url = format!("{}/transfer/sol", self.base_url);
let body = TransferSolRequest {
user_private_key: user_private_key.to_string(),
destination: destination.to_string(),
amount_lamports,
};
let response = self.post_with_retry(&url, &body).await?;
self.handle_success_response(response).await
}
pub async fn transfer_spl(
&self,
user_private_key: &str,
destination: &str,
token_mint: &str,
amount: &str,
) -> Result<TransferResponse, AppError> {
let url = format!("{}/transfer/spl", self.base_url);
let body = TransferSplRequest {
user_private_key: user_private_key.to_string(),
destination: destination.to_string(),
token_mint: token_mint.to_string(),
amount: amount.to_string(),
};
let response = self.post_with_retry(&url, &body).await?;
self.handle_success_response(response).await
}
pub async fn get_wallet_balances(
&self,
wallet_address: &str,
) -> Result<WalletBalancesResponse, AppError> {
let url = format!("{}/transfer/balances", self.base_url);
let response = self
.client
.post(&url)
.header("Authorization", format!("Bearer {}", self.api_key))
.json(&WalletBalancesRequest {
wallet_address: wallet_address.to_string(),
})
.send()
.await
.map_err(|e| AppError::Internal(e.into()))?;
self.handle_response(response).await
}
pub async fn verify_sol_transfer(
&self,
signature: &str,
expected_source: &str,
expected_destination: &str,
min_lamports: Option<u64>,
) -> Result<VerifySolTransferResponse, AppError> {
let url = format!("{}/verify/sol-transfer", self.base_url);
let response = self
.client
.post(&url)
.header("Authorization", format!("Bearer {}", self.api_key))
.json(&VerifySolTransferRequest {
signature: signature.to_string(),
expected_source: expected_source.to_string(),
expected_destination: expected_destination.to_string(),
min_lamports,
})
.send()
.await
.map_err(|e| AppError::Internal(e.into()))?;
let verified: VerifySolTransferResponse = self.handle_response(response).await?;
if !verified.ok {
return Err(AppError::Internal(anyhow::anyhow!(
"Sidecar verification failed"
)));
}
Ok(verified)
}
async fn handle_response<T: for<'de> Deserialize<'de>>(
&self,
response: reqwest::Response,
) -> Result<T, AppError> {
let status = response.status();
if status.is_success() {
response
.json()
.await
.map_err(|e| AppError::Internal(e.into()))
} else {
let error_text = response.text().await.unwrap_or_default();
if let Ok(error_response) = serde_json::from_str::<ErrorResponse>(&error_text) {
let message = if let Some(details) = error_response.details {
format!("{}: {}", error_response.error, details)
} else {
error_response.error
};
Err(AppError::Internal(anyhow::anyhow!("{}", message)))
} else {
Err(AppError::Internal(anyhow::anyhow!(
"Sidecar request failed ({}): {}",
status,
error_text
)))
}
}
}
async fn handle_success_response<T: for<'de> Deserialize<'de> + SidecarSuccess>(
&self,
response: reqwest::Response,
) -> Result<T, AppError> {
let status = response.status();
if status.is_success() {
let value: T = response
.json()
.await
.map_err(|e| AppError::Internal(e.into()))?;
ensure_sidecar_success(value)
} else {
self.handle_response(response).await
}
}
}
#[cfg(test)]
mod verify_tests {
use super::*;
use axum::{routing::post, Json, Router};
use serde_json::json;
#[tokio::test]
async fn verify_sol_transfer_calls_sidecar_and_parses_response() {
async fn handler(Json(body): Json<serde_json::Value>) -> Json<serde_json::Value> {
assert_eq!(body["signature"], "sig1");
assert_eq!(body["expectedSource"], "src1");
assert_eq!(body["expectedDestination"], "dst1");
Json(json!({
"ok": true,
"signature": "sig1",
"observedLamports": 123,
"source": "src1",
"destination": "dst1"
}))
}
let app = Router::new().route("/verify/sol-transfer", post(handler));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let client = PrivacySidecarClient::new(SidecarClientConfig {
base_url: format!("http://{}", addr),
timeout_ms: 2_000,
api_key: "k".to_string(),
})
.unwrap();
let result = client
.verify_sol_transfer("sig1", "src1", "dst1", Some(100))
.await
.unwrap();
assert!(result.ok);
assert_eq!(result.observed_lamports, 123);
server.abort();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_deposit_request_serialization() {
let request = DepositRequest {
user_private_key: "privatekey123".to_string(),
amount_lamports: 500_000_000,
};
let json = serde_json::to_string(&request).unwrap();
assert!(json.contains("\"user_private_key\":\"privatekey123\""));
assert!(json.contains("\"amount_lamports\":500000000"));
}
#[test]
fn test_deposit_response_deserialization() {
let json = r#"{
"success": true,
"tx_signature": "sig456",
"user_pubkey": "abc123"
}"#;
let response: DepositResponse = serde_json::from_str(json).unwrap();
assert!(response.success);
assert_eq!(response.tx_signature, "sig456");
assert_eq!(response.user_pubkey, "abc123");
}
#[test]
fn test_ensure_sidecar_success_rejects_success_false() {
let value = DepositResponse {
success: false,
tx_signature: "".to_string(),
user_pubkey: "".to_string(),
};
assert!(ensure_sidecar_success(value).is_err());
let value = BatchSwapResponse {
success: false,
tx_signature: "".to_string(),
input_lamports: 0,
output_amount: "0".to_string(),
output_currency: "USDC".to_string(),
error: Some("insufficient funds".to_string()),
};
let err = ensure_sidecar_success(value).unwrap_err();
match err {
AppError::Internal(e) => assert!(e.to_string().contains("insufficient funds")),
other => panic!("Unexpected error: {other:?}"),
}
}
#[test]
fn test_withdraw_request_serialization() {
let request = WithdrawRequest {
user_private_key: "privatekey456".to_string(),
amount_lamports: 100_000_000,
target_currency: None,
};
let json = serde_json::to_string(&request).unwrap();
assert!(json.contains("\"user_private_key\":\"privatekey456\""));
assert!(json.contains("\"amount_lamports\":100000000"));
assert!(!json.contains("target_currency"));
}
#[test]
fn test_withdraw_request_with_currency_serialization() {
let request = WithdrawRequest {
user_private_key: "privatekey789".to_string(),
amount_lamports: 50_000_000,
target_currency: Some("USDC".to_string()),
};
let json = serde_json::to_string(&request).unwrap();
assert!(json.contains("\"target_currency\":\"USDC\""));
}
#[test]
fn test_withdraw_response_deserialization() {
let json = r#"{
"success": true,
"tx_signature": "sig123",
"fee_lamports": 6000000,
"amount_lamports": 94000000,
"is_partial": false
}"#;
let response: WithdrawResponse = serde_json::from_str(json).unwrap();
assert!(response.success);
assert_eq!(response.tx_signature, "sig123");
assert_eq!(response.fee_lamports, 6000000);
assert_eq!(response.amount_lamports, 94000000);
assert!(!response.is_partial);
}
#[test]
fn test_withdraw_response_partial() {
let json = r#"{
"success": true,
"tx_signature": "sig456",
"fee_lamports": 5000000,
"amount_lamports": 50000000,
"is_partial": true
}"#;
let response: WithdrawResponse = serde_json::from_str(json).unwrap();
assert!(response.success);
assert!(response.is_partial);
assert_eq!(response.amount_lamports, 50000000);
}
#[test]
fn test_balance_response_deserialization() {
let json = r#"{
"balance_lamports": 1000000000,
"balance_sol": 1.0,
"user_pubkey": "xyz789"
}"#;
let response: BalanceResponse = serde_json::from_str(json).unwrap();
assert_eq!(response.balance_lamports, 1000000000);
assert_eq!(response.balance_sol, 1.0);
assert_eq!(response.user_pubkey, "xyz789");
}
#[test]
fn test_swap_and_deposit_request_serialization() {
let request = SwapAndDepositRequest {
user_private_key: "privatekey789".to_string(),
input_mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string(),
amount: "10000000".to_string(),
};
let json = serde_json::to_string(&request).unwrap();
assert!(json.contains("\"user_private_key\":\"privatekey789\""));
assert!(json.contains("\"input_mint\":\"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v\""));
assert!(json.contains("\"amount\":\"10000000\""));
}
#[test]
fn test_swap_and_deposit_response_deserialization() {
let json = r#"{
"success": true,
"swap_tx_signature": "swap123",
"deposit_tx_signature": "deposit456",
"sol_amount_lamports": 50000000,
"input_mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
"input_amount": "10000000",
"user_pubkey": "user789"
}"#;
let response: SwapAndDepositResponse = serde_json::from_str(json).unwrap();
assert!(response.success);
assert_eq!(response.swap_tx_signature, "swap123");
assert_eq!(response.deposit_tx_signature, "deposit456");
assert_eq!(response.sol_amount_lamports, 50000000);
assert_eq!(
response.input_mint,
"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
);
assert_eq!(response.input_amount, "10000000");
assert_eq!(response.user_pubkey, "user789");
}
}