use axum::{
extract::{Path, Query, State},
http::HeaderMap,
Json,
};
use std::sync::Arc;
use uuid::Uuid;
#[cfg(feature = "postgres")]
use sqlx::FromRow;
use crate::callback::AuthCallback;
use crate::errors::AppError;
use crate::models::{
ConfirmSplDepositRequest, ConfirmSplDepositResponse, DepositConfigResponse,
DepositItemResponse, DepositListResponse, DepositStatusResponse, MessageResponse,
PendingSplDepositItemResponse, PendingSplDepositListResponse,
};
use crate::services::{DepositService, EmailService, SolPriceService};
use crate::utils::authenticate;
use crate::AppState;
#[derive(Debug, serde::Deserialize)]
pub struct PrivacyDepositRequest {
pub amount_lamports: u64,
}
#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PrivacyDepositResponse {
pub session_id: Uuid,
pub tx_signature: String,
pub amount_lamports: i64,
pub message: String,
pub withdrawal_available_at: chrono::DateTime<chrono::Utc>,
}
pub(crate) fn create_deposit_service<C: AuthCallback, E: EmailService>(
state: &Arc<AppState<C, E>>,
) -> Result<DepositService, AppError> {
let sidecar = state.privacy_sidecar_client.clone().ok_or_else(|| {
AppError::Internal(anyhow::anyhow!("Privacy sidecar client not configured"))
})?;
Ok(DepositService::new(
state.deposit_repo.clone(),
state.credit_repo.clone(),
sidecar,
state.deposit_credit_service.clone(),
&state.config.privacy,
))
}
pub async fn execute_deposit<C: AuthCallback, E: EmailService>(
State(state): State<Arc<AppState<C, E>>>,
headers: HeaderMap,
Json(request): Json<PrivacyDepositRequest>,
) -> Result<Json<PrivacyDepositResponse>, AppError> {
if !state.config.privacy.enabled {
return Err(AppError::NotFound("Privacy deposits not enabled".into()));
}
use crate::config::WalletRecoveryMode;
if state.config.wallet.recovery_mode != WalletRecoveryMode::None {
return Err(AppError::Validation(
"Privacy deposits require no-recovery wallet mode. Contact administrator.".into(),
));
}
let auth_user = authenticate(&state, &headers).await?;
let wallet_material = state
.wallet_material_repo
.find_by_user(auth_user.user_id)
.await?
.ok_or_else(|| {
AppError::NotFound(
"SSS wallet not enrolled. Privacy deposits require SSS wallet.".into(),
)
})?;
let session_id_for_cache = auth_user.session_id.ok_or_else(|| {
AppError::Unauthorized("Session required for embedded wallet operations".into())
})?;
let cached_key = state
.wallet_unlock_cache
.get(session_id_for_cache)
.await
.ok_or_else(|| {
AppError::Unauthorized("Wallet is locked. Call POST /wallet/unlock first.".into())
})?;
let user_private_key = state
.wallet_signing_service
.reconstruct_private_key(&wallet_material, &cached_key)
.map_err(|e| {
tracing::error!(error = %e, "Failed to reconstruct private key for deposit");
AppError::Internal(anyhow::anyhow!("Failed to reconstruct wallet key"))
})?;
let note_encryption = state
.note_encryption_service
.as_ref()
.ok_or_else(|| AppError::Config("Note encryption not configured".into()))?;
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
let encrypted = note_encryption.encrypt(user_private_key.as_bytes())?;
let mut combined = encrypted.nonce;
combined.extend(&encrypted.ciphertext);
let encrypted_private_key = BASE64.encode(&combined);
let privacy_period_secs = state
.settings_service
.get_u64("privacy_period_secs")
.await?
.unwrap_or(604800);
let deposit_service = create_deposit_service(&state)?;
let result = deposit_service
.execute_deposit(
auth_user.user_id,
&user_private_key,
&encrypted_private_key,
request.amount_lamports,
privacy_period_secs,
)
.await?;
let sol_amount = result.amount_lamports as f64 / 1_000_000_000.0;
Ok(Json(PrivacyDepositResponse {
session_id: result.session_id,
tx_signature: result.tx_signature,
amount_lamports: result.amount_lamports,
message: format!("Successfully deposited {:.4} SOL", sol_amount),
withdrawal_available_at: result.withdrawal_available_at,
}))
}
pub async fn deposit_status<C: AuthCallback, E: EmailService>(
State(state): State<Arc<AppState<C, E>>>,
headers: HeaderMap,
Path(session_id): Path<Uuid>,
) -> Result<Json<DepositStatusResponse>, AppError> {
if !state.config.privacy.enabled {
return Err(AppError::NotFound("Privacy deposits not enabled".into()));
}
let auth_user = authenticate(&state, &headers).await?;
let deposit_service = create_deposit_service(&state)?;
let session = deposit_service
.get_session(session_id, auth_user.user_id)
.await?;
Ok(Json(DepositStatusResponse::from(&session)))
}
const DEFAULT_PRIVATE_MIN_LAMPORTS: u64 = 250_000_000;
const LAMPORTS_PER_SOL: f64 = 1_000_000_000.0;
const JUPITER_MIN_USD: f64 = 10.0;
const DEFAULT_QUICK_ACTION_TOKENS: &str = "USDC,USDT,EURC";
const DEFAULT_CUSTOM_TOKENS: &str = "SOL,USDC,USDT,EURC,USD1,PYUSD,USDH,CASH,BONK,ORE";
const BONK_MINT: &str = "DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263";
const ORE_MINT: &str = "oreoU2P8bN6jkk3jbaiVxYnG1dCXcYxwhwyK9jSybcp";
const EURC_MINT: &str = "HzwqbKZw8HxMN6bF2yFZNrht3c2iXXzpKcFu7uBEDKtr";
fn parse_token_list(value: String) -> Vec<String> {
value
.split(',')
.map(|token| token.trim())
.filter(|token| !token.is_empty())
.map(|token| token.to_uppercase())
.collect()
}
pub async fn deposit_config<C: AuthCallback, E: EmailService>(
State(state): State<Arc<AppState<C, E>>>,
) -> Result<Json<DepositConfigResponse>, AppError> {
let privacy_period_secs = state
.settings_service
.get_u64("privacy_period_secs")
.await?
.unwrap_or(604800);
let sol_price_usd = state.sol_price_service.get_sol_price_usd().await?;
let private_min_lamports = state
.settings_service
.get_u64("private_deposit_min_lamports")
.await?
.unwrap_or(DEFAULT_PRIVATE_MIN_LAMPORTS);
let private_min_sol = private_min_lamports as f64 / LAMPORTS_PER_SOL;
let private_min_usd_raw = private_min_sol * sol_price_usd;
let private_min_usd = SolPriceService::round_up_to_nearest_5(private_min_usd_raw);
let company_wallet = state
.config
.privacy
.company_wallet_address
.clone()
.unwrap_or_default();
let company_currency = state.config.privacy.company_currency.clone();
let treasury_config = state.treasury_config_repo.find_for_org(None).await?;
let micro_deposit_address = treasury_config.map(|c| c.wallet_address);
let micro_batch_threshold_usd = state
.settings_service
.get_u64("micro_batch_threshold_usd")
.await?
.map(|v| v as f64)
.unwrap_or(JUPITER_MIN_USD);
let fee_config = state.deposit_credit_service.get_fee_config().await?;
let fee_policy = match fee_config.policy {
crate::services::FeePolicy::CompanyPaysAll => "company_pays_all",
crate::services::FeePolicy::UserPaysSwap => "user_pays_swap",
crate::services::FeePolicy::UserPaysPrivacy => "user_pays_privacy",
crate::services::FeePolicy::UserPaysAll => "user_pays_all",
};
let quick_action_tokens = state
.settings_service
.get("deposit_quick_action_tokens")
.await?
.unwrap_or_else(|| DEFAULT_QUICK_ACTION_TOKENS.to_string());
let custom_token_symbols = state
.settings_service
.get("deposit_custom_tokens")
.await?
.unwrap_or_else(|| DEFAULT_CUSTOM_TOKENS.to_string());
let token_mints = &[BONK_MINT, ORE_MINT, EURC_MINT];
let mint_prices = state
.sol_price_service
.get_token_prices(token_mints)
.await
.unwrap_or_default();
let mut token_prices = std::collections::HashMap::new();
token_prices.insert("SOL".to_string(), sol_price_usd);
if let Some(&price) = mint_prices.get(BONK_MINT) {
token_prices.insert("BONK".to_string(), price);
}
if let Some(&price) = mint_prices.get(ORE_MINT) {
token_prices.insert("ORE".to_string(), price);
}
if let Some(&price) = mint_prices.get(EURC_MINT) {
token_prices.insert("EURC".to_string(), price);
}
use crate::config::WalletRecoveryMode;
let private_deposits_enabled = state.config.wallet.recovery_mode == WalletRecoveryMode::None;
let show_explainer = state
.settings_service
.get_bool("deposit_show_explainer")
.await?
.unwrap_or(false);
let custom_tokens: Option<Vec<crate::models::CustomTokenDefinition>> = state
.settings_service
.get("deposit_custom_tokens_json")
.await?
.and_then(|json_str| serde_json::from_str(&json_str).ok());
Ok(Json(DepositConfigResponse {
enabled: state.config.privacy.enabled,
private_deposits_enabled,
privacy_period_secs,
company_wallet,
company_currency,
sol_price_usd,
token_prices,
private_min_sol,
private_min_usd,
public_min_usd: JUPITER_MIN_USD,
sol_micro_max_usd: JUPITER_MIN_USD,
supported_currencies: vec!["SOL".to_string(), "USDC".to_string(), "USDT".to_string()],
quick_action_tokens: parse_token_list(quick_action_tokens),
custom_token_symbols: parse_token_list(custom_token_symbols),
micro_deposit_address,
micro_batch_threshold_usd,
fee_policy: fee_policy.to_string(),
privacy_fee_percent: fee_config.privacy_percent_bps as f64 / 100.0,
privacy_fee_fixed_lamports: fee_config.privacy_fixed_lamports,
swap_fee_percent: fee_config.swap_percent_bps as f64 / 100.0,
swap_fee_fixed_lamports: fee_config.swap_fixed_lamports,
company_fee_percent: fee_config.company_percent_bps as f64 / 100.0,
company_fee_fixed_lamports: fee_config.company_fixed_lamports,
show_explainer,
custom_tokens,
}))
}
pub async fn cancel_deposit<C: AuthCallback, E: EmailService>(
State(state): State<Arc<AppState<C, E>>>,
headers: HeaderMap,
Path(_session_id): Path<Uuid>,
) -> Result<Json<MessageResponse>, AppError> {
if !state.config.privacy.enabled {
return Err(AppError::NotFound("Privacy deposits not enabled".into()));
}
let _auth_user = authenticate(&state, &headers).await?;
Err(AppError::Validation(
"Privacy deposits cannot be cancelled after execution".into(),
))
}
#[derive(Debug, serde::Deserialize)]
pub struct ListDepositsQuery {
#[serde(default = "default_limit")]
pub limit: u32,
#[serde(default)]
pub offset: u32,
}
fn default_limit() -> u32 {
20
}
pub async fn list_deposits<C: AuthCallback, E: EmailService>(
State(state): State<Arc<AppState<C, E>>>,
headers: HeaderMap,
Query(query): Query<ListDepositsQuery>,
) -> Result<Json<DepositListResponse>, AppError> {
if !state.config.privacy.enabled {
return Err(AppError::NotFound("Privacy deposits not enabled".into()));
}
let auth_user = authenticate(&state, &headers).await?;
let limit = query.limit.min(100);
let deposit_service = create_deposit_service(&state)?;
let result = deposit_service
.list_deposits(auth_user.user_id, limit, query.offset)
.await?;
Ok(Json(DepositListResponse {
deposits: result
.deposits
.iter()
.map(DepositItemResponse::from)
.collect(),
total: result.total,
limit: result.limit,
offset: result.offset,
}))
}
#[cfg(feature = "postgres")]
#[derive(Debug, FromRow)]
struct PendingSplDepositRow {
id: Uuid,
wallet_address: String,
token_mint: String,
token_amount_raw: String,
token_amount: Option<i64>,
tx_signature: String,
created_at: chrono::DateTime<chrono::Utc>,
expires_at: chrono::DateTime<chrono::Utc>,
}
pub async fn list_pending_spl_deposits<C: AuthCallback, E: EmailService>(
State(state): State<Arc<AppState<C, E>>>,
headers: HeaderMap,
Query(query): Query<ListDepositsQuery>,
) -> Result<Json<PendingSplDepositListResponse>, AppError> {
if !state.config.privacy.enabled {
return Err(AppError::NotFound("Privacy deposits not enabled".into()));
}
let auth_user = authenticate(&state, &headers).await?;
let limit = query.limit.min(100);
#[cfg(feature = "postgres")]
let pool = state.postgres_pool.as_ref().ok_or_else(|| {
AppError::Config("Postgres pool is required for pending SPL deposits".into())
})?;
#[cfg(not(feature = "postgres"))]
{
let _ = auth_user;
let _ = limit;
return Err(AppError::Config(
"Pending SPL deposits require the 'postgres' feature".into(),
));
}
#[cfg(feature = "postgres")]
{
let rows: Vec<PendingSplDepositRow> = sqlx::query_as(
r#"
SELECT id, wallet_address, token_mint, token_amount_raw, token_amount,
tx_signature, created_at, expires_at
FROM pending_spl_deposits
WHERE user_id = $1
AND status = 'pending'
AND expires_at > NOW()
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
"#,
)
.bind(auth_user.user_id)
.bind(limit as i64)
.bind(query.offset as i64)
.fetch_all(pool)
.await
.map_err(|e| AppError::Internal(e.into()))?;
let total: i64 = sqlx::query_scalar(
r#"
SELECT COUNT(*)
FROM pending_spl_deposits
WHERE user_id = $1
AND status = 'pending'
AND expires_at > NOW()
"#,
)
.bind(auth_user.user_id)
.fetch_one(pool)
.await
.map_err(|e| AppError::Internal(e.into()))?;
let deposits = rows
.into_iter()
.filter_map(|r| {
let token_amount = r.token_amount?;
Some(PendingSplDepositItemResponse {
id: r.id,
wallet_address: r.wallet_address,
token_mint: r.token_mint,
token_amount_raw: r.token_amount_raw,
token_amount,
tx_signature: r.tx_signature,
created_at: r.created_at,
expires_at: r.expires_at,
})
})
.collect();
Ok(Json(PendingSplDepositListResponse {
deposits,
total: total as u64,
limit,
offset: query.offset,
}))
}
}
pub async fn confirm_spl_deposit<C: AuthCallback, E: EmailService>(
State(state): State<Arc<AppState<C, E>>>,
headers: HeaderMap,
Json(request): Json<ConfirmSplDepositRequest>,
) -> Result<Json<ConfirmSplDepositResponse>, AppError> {
if !state.config.privacy.enabled {
return Err(AppError::NotFound("Privacy deposits not enabled".into()));
}
use crate::config::WalletRecoveryMode;
if state.config.wallet.recovery_mode != WalletRecoveryMode::None {
return Err(AppError::Validation(
"Privacy deposits require no-recovery wallet mode. Contact administrator.".into(),
));
}
let auth_user = authenticate(&state, &headers).await?;
#[cfg(feature = "postgres")]
let pool = state.postgres_pool.as_ref().ok_or_else(|| {
AppError::Config("Postgres pool is required for confirming SPL deposits".into())
})?;
#[cfg(not(feature = "postgres"))]
{
let _ = auth_user;
let _ = request;
return Err(AppError::Config(
"Confirming SPL deposits requires the 'postgres' feature".into(),
));
}
#[cfg(feature = "postgres")]
let pending: PendingSplDepositRow = {
let row = sqlx::query_as::<_, PendingSplDepositRow>(
r#"
UPDATE pending_spl_deposits
SET status = 'processing'
WHERE id = $1
AND user_id = $2
AND status = 'pending'
AND expires_at > NOW()
RETURNING id, wallet_address, token_mint, token_amount_raw, token_amount,
tx_signature, created_at, expires_at
"#,
)
.bind(request.pending_id)
.bind(auth_user.user_id)
.fetch_optional(pool)
.await
.map_err(|e| AppError::Internal(e.into()))?;
row.ok_or_else(|| AppError::NotFound("Pending SPL deposit not found".into()))?
};
let token_amount = pending
.token_amount
.ok_or_else(|| AppError::Validation("Pending SPL deposit missing token_amount".into()))?;
if !state.config.privacy.is_token_whitelisted(&pending.token_mint) {
tracing::warn!(
pending_id = %pending.id,
token_mint = %pending.token_mint,
user_id = %auth_user.user_id,
"Attempted to confirm deposit with non-whitelisted token"
);
return Err(AppError::Validation(format!(
"Token {} is not whitelisted for deposits",
pending.token_mint
)));
}
let wallet_material = state
.wallet_material_repo
.find_by_user(auth_user.user_id)
.await?
.ok_or_else(|| AppError::NotFound("SSS wallet not enrolled".into()))?;
let session_id_for_cache = auth_user.session_id.ok_or_else(|| {
AppError::Unauthorized("Session required for embedded wallet operations".into())
})?;
let cached_key = state
.wallet_unlock_cache
.get(session_id_for_cache)
.await
.ok_or_else(|| {
AppError::Unauthorized("Wallet is locked. Call POST /wallet/unlock first.".into())
})?;
let user_private_key = state
.wallet_signing_service
.reconstruct_private_key(&wallet_material, &cached_key)
.map_err(|e| {
tracing::error!(error = %e, "Failed to reconstruct private key for SPL deposit");
AppError::Internal(anyhow::anyhow!("Failed to reconstruct wallet key"))
})?;
let note_encryption = state
.note_encryption_service
.as_ref()
.ok_or_else(|| AppError::Config("Note encryption not configured".into()))?;
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
let encrypted = note_encryption.encrypt(user_private_key.as_bytes())?;
let mut combined = encrypted.nonce;
combined.extend(&encrypted.ciphertext);
let encrypted_private_key = BASE64.encode(&combined);
let privacy_period_secs = state
.settings_service
.get_u64("privacy_period_secs")
.await?
.unwrap_or(604800);
let deposit_service = create_deposit_service(&state)?;
let result = deposit_service
.execute_spl_deposit(
auth_user.user_id,
&user_private_key,
&encrypted_private_key,
&pending.token_mint,
&token_amount.to_string(),
privacy_period_secs,
)
.await;
#[cfg(feature = "postgres")]
match result {
Ok(ok) => {
let _ = sqlx::query(
r#"
UPDATE pending_spl_deposits
SET status = 'completed',
deposit_session_id = $2,
processed_at = NOW(),
error_message = NULL
WHERE id = $1
"#,
)
.bind(pending.id)
.bind(ok.session_id)
.execute(pool)
.await;
Ok(Json(ConfirmSplDepositResponse {
success: true,
pending_id: pending.id,
deposit_session_id: Some(ok.session_id),
swap_tx_signature: Some(ok.swap_tx_signature),
deposit_tx_signature: Some(ok.deposit_tx_signature),
error: None,
}))
}
Err(e) => {
let msg = e.to_string();
let _ = sqlx::query(
r#"
UPDATE pending_spl_deposits
SET status = 'failed',
processed_at = NOW(),
error_message = $2
WHERE id = $1
"#,
)
.bind(pending.id)
.bind(&msg)
.execute(pool)
.await;
Ok(Json(ConfirmSplDepositResponse {
success: false,
pending_id: pending.id,
deposit_session_id: None,
swap_tx_signature: None,
deposit_tx_signature: None,
error: Some(msg),
}))
}
}
}