use axum::{
extract::{Query, State},
http::HeaderMap,
Json,
};
use std::sync::Arc;
use uuid::Uuid;
use crate::callback::AuthCallback;
use crate::errors::AppError;
use crate::models::DepositQuoteResponse;
use crate::services::{EmailService, JupiterOrderParams, TieredDepositService};
use crate::utils::authenticate;
use crate::AppState;
const LAMPORTS_PER_SOL: f64 = 1_000_000_000.0;
fn parse_total_output_amount(total_output_amount: Option<&str>) -> Result<i64, AppError> {
let raw = total_output_amount.ok_or_else(|| {
AppError::Internal(anyhow::anyhow!(
"Jupiter execute succeeded but missing total_output_amount"
))
})?;
raw.parse::<i64>().map_err(|e| {
AppError::Internal(anyhow::anyhow!(
"Invalid Jupiter total_output_amount '{}': {}",
raw,
e
))
})
}
#[derive(Debug, serde::Deserialize)]
pub struct DepositQuoteQuery {
pub input_mint: String,
pub amount: u64,
pub taker: String,
}
pub async fn deposit_quote<C: AuthCallback, E: EmailService>(
State(state): State<Arc<AppState<C, E>>>,
headers: HeaderMap,
Query(query): Query<DepositQuoteQuery>,
) -> Result<Json<DepositQuoteResponse>, AppError> {
if !state.config.privacy.enabled {
return Err(AppError::NotFound("Deposits not enabled".into()));
}
let _auth_user = authenticate(&state, &headers).await?;
let jupiter = state.jupiter_swap_service.as_ref().ok_or_else(|| {
AppError::Internal(anyhow::anyhow!("Jupiter swap service not configured"))
})?;
let order = jupiter
.get_order(&JupiterOrderParams {
input_mint: query.input_mint,
amount: query.amount,
taker: query.taker,
})
.await?;
let transaction = order
.transaction
.ok_or_else(|| AppError::Internal(anyhow::anyhow!("Jupiter order missing transaction")))?;
Ok(Json(DepositQuoteResponse {
input_mint: order.input_mint,
output_mint: order.output_mint,
in_amount: order.in_amount,
out_amount: order.out_amount,
in_usd_value: order.in_usd_value,
out_usd_value: order.out_usd_value,
slippage_bps: order.slippage_bps,
transaction,
request_id: order.request_id,
}))
}
#[derive(Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PublicDepositRequest {
pub signed_transaction: String,
pub request_id: String,
pub input_mint: String,
pub input_amount: u64,
pub wallet_address: String,
}
#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TieredDepositResponse {
pub session_id: Uuid,
pub tx_signature: String,
pub message: String,
pub deposit_type: String,
}
pub async fn execute_public_deposit<C: AuthCallback, E: EmailService>(
State(state): State<Arc<AppState<C, E>>>,
headers: HeaderMap,
Json(request): Json<PublicDepositRequest>,
) -> Result<Json<TieredDepositResponse>, AppError> {
if !state.config.privacy.enabled {
return Err(AppError::NotFound("Deposits not enabled".into()));
}
let auth_user = authenticate(&state, &headers).await?;
let jupiter = state.jupiter_swap_service.as_ref().ok_or_else(|| {
AppError::Internal(anyhow::anyhow!("Jupiter swap service not configured"))
})?;
let execute_result = jupiter
.execute_order(&request.signed_transaction, &request.request_id)
.await?;
if !execute_result.is_success() {
let error_msg = execute_result
.error
.unwrap_or_else(|| "Unknown error".to_string());
return Err(AppError::Internal(anyhow::anyhow!(
"Jupiter swap failed: {}",
error_msg
)));
}
let tx_signature = execute_result.signature.ok_or_else(|| {
AppError::Internal(anyhow::anyhow!(
"Jupiter execute succeeded but no signature"
))
})?;
let output_amount = parse_total_output_amount(execute_result.total_output_amount.as_deref())?;
let tiered_service = TieredDepositService::new(
state.deposit_repo.clone(),
state.credit_repo.clone(),
state.deposit_credit_service.clone(),
);
let result = tiered_service
.record_public_deposit(
auth_user.user_id,
&request.wallet_address,
&tx_signature,
output_amount,
&state.config.privacy.company_currency,
Some(&request.input_mint),
Some(request.input_amount as i64),
)
.await?;
Ok(Json(TieredDepositResponse {
session_id: result.session_id,
tx_signature: result.tx_signature,
message: "Public deposit completed successfully".to_string(),
deposit_type: "public".to_string(),
}))
}
#[derive(Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MicroDepositRequest {
pub tx_signature: String,
pub amount_lamports: u64,
pub wallet_address: String,
}
pub async fn execute_micro_deposit<C: AuthCallback, E: EmailService>(
State(state): State<Arc<AppState<C, E>>>,
headers: HeaderMap,
Json(request): Json<MicroDepositRequest>,
) -> Result<Json<TieredDepositResponse>, AppError> {
if !state.config.privacy.enabled {
return Err(AppError::NotFound("Deposits not enabled".into()));
}
let auth_user = authenticate(&state, &headers).await?;
let treasury_config = state
.treasury_config_repo
.find_for_org(None)
.await?
.ok_or_else(|| AppError::Config("Treasury wallet not configured".into()))?;
let treasury_wallet_address = treasury_config.wallet_address;
let user = state
.user_repo
.find_by_id(auth_user.user_id)
.await?
.ok_or(AppError::InvalidToken)?;
let mut allowed_senders: Vec<String> = Vec::new();
if let Some(wallet) = user.wallet_address.clone() {
allowed_senders.push(wallet);
}
if let Some(material) = state
.wallet_material_repo
.find_default_by_user(auth_user.user_id)
.await?
{
allowed_senders.push(material.solana_pubkey);
}
if allowed_senders.is_empty() {
return Err(AppError::Forbidden(
"Micro deposits require a linked Solana wallet".into(),
));
}
if !allowed_senders
.iter()
.any(|w| w.as_str() == request.wallet_address.as_str())
{
return Err(AppError::Forbidden(
"Micro deposit sender does not match your linked wallet".into(),
));
}
if let Some(existing) = state
.deposit_repo
.find_micro_by_tx_signature(&request.tx_signature)
.await?
{
if existing.user_id != auth_user.user_id {
return Err(AppError::Forbidden(
"Micro deposit transaction already claimed".into(),
));
}
let amount_lamports = existing.detected_amount_lamports.unwrap_or(0);
let sol_amount = amount_lamports as f64 / LAMPORTS_PER_SOL;
return Ok(Json(TieredDepositResponse {
session_id: existing.session_id,
tx_signature: request.tx_signature,
message: format!("SOL micro deposit already recorded ({:.4} SOL)", sol_amount),
deposit_type: "sol_micro".to_string(),
}));
}
let sidecar = state
.privacy_sidecar_client
.as_ref()
.ok_or_else(|| AppError::Config("Privacy sidecar not configured".into()))?;
let verified = sidecar
.verify_sol_transfer(
&request.tx_signature,
&request.wallet_address,
&treasury_wallet_address,
Some(request.amount_lamports),
)
.await?;
let amount_lamports: i64 = verified
.observed_lamports
.try_into()
.map_err(|_| AppError::Validation("Deposit amount overflow".into()))?;
let tiered_service = TieredDepositService::new(
state.deposit_repo.clone(),
state.credit_repo.clone(),
state.deposit_credit_service.clone(),
);
let result = tiered_service
.record_micro_deposit(
auth_user.user_id,
&request.wallet_address,
&request.tx_signature,
amount_lamports,
)
.await?;
let sol_amount = result.amount_lamports as f64 / LAMPORTS_PER_SOL;
Ok(Json(TieredDepositResponse {
session_id: result.session_id,
tx_signature: result.tx_signature,
message: format!("SOL micro deposit of {:.4} SOL credited", sol_amount),
deposit_type: "sol_micro".to_string(),
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_total_output_amount_ok() {
assert_eq!(parse_total_output_amount(Some("123")).unwrap(), 123);
}
#[test]
fn test_parse_total_output_amount_rejects_missing_or_invalid() {
assert!(parse_total_output_amount(None).is_err());
assert!(parse_total_output_amount(Some("not-a-number")).is_err());
}
}