Skip to main content

hypercall_admin/monitoring/
tiers.rs

1//! Admin set-tier endpoint (used by market maker self-registration).
2
3use axum::{extract::State, http::StatusCode, response::IntoResponse};
4use serde::Deserialize;
5use sonic_rs::json;
6
7use crate::state::AdminState;
8use hypercall_db::UserTierUpdate;
9use hypercall_runtime_api::sonic_json::SonicJson;
10use hypercall_types::WalletAddress;
11
12#[derive(Debug, Deserialize, utoipa::ToSchema)]
13pub struct AdminSetTierRequest {
14    pub wallet: String,
15    pub tier: String,
16}
17
18/// POST /monitoring/set-tier - Set a user's tier with limits from tier_defaults
19///
20/// Looks up the requested tier in `tier_defaults` and applies those limits
21/// to the wallet in `user_tiers`. Used by market makers to self-register on boot.
22///
23/// **Authentication**: Requires `X-Admin-Key` header.
24#[utoipa::path(
25    post,
26    path = "/monitoring/set-tier",
27    request_body = AdminSetTierRequest,
28    responses(
29        (status = 200, description = "Tier set successfully"),
30        (status = 400, description = "Invalid request"),
31        (status = 404, description = "Tier not found in tier_defaults"),
32        (status = 401, description = "Invalid or missing X-Admin-Key header"),
33        (status = 500, description = "Internal error")
34    ),
35    tag = "Monitoring",
36    security(("admin_key" = []))
37)]
38pub async fn set_tier(
39    State(app_state): State<AdminState>,
40    SonicJson(request): SonicJson<AdminSetTierRequest>,
41) -> impl IntoResponse {
42    // Parse wallet address
43    let wallet: WalletAddress = match request.wallet.parse() {
44        Ok(w) => w,
45        Err(e) => {
46            return (
47                StatusCode::BAD_REQUEST,
48                SonicJson(json!({
49                    "error": format!("Invalid wallet address: {}", e),
50                })),
51            )
52                .into_response();
53        }
54    };
55
56    // Look up tier defaults from DB
57    let db_handler = match &app_state.sync_db {
58        Some(h) => h.clone(),
59        None => {
60            return (
61                StatusCode::INTERNAL_SERVER_ERROR,
62                SonicJson(json!({
63                    "error": "Diesel handler not available",
64                })),
65            )
66                .into_response();
67        }
68    };
69
70    let tier_name = request.tier.clone();
71    let handler_clone = db_handler.clone();
72    let tier_defaults =
73        match tokio::task::spawn_blocking(move || handler_clone.get_tier_defaults_sync(&tier_name))
74            .await
75        {
76            Ok(Ok(Some(defaults))) => defaults,
77            Ok(Ok(None)) => {
78                return (
79                    StatusCode::NOT_FOUND,
80                    SonicJson(json!({
81                        "error": format!("Tier '{}' not found in tier_defaults", request.tier),
82                    })),
83                )
84                    .into_response();
85            }
86            Ok(Err(e)) => {
87                return (
88                    StatusCode::INTERNAL_SERVER_ERROR,
89                    SonicJson(json!({
90                        "error": format!("Failed to query tier_defaults: {}", e),
91                    })),
92                )
93                    .into_response();
94            }
95            Err(e) => {
96                return (
97                    StatusCode::INTERNAL_SERVER_ERROR,
98                    SonicJson(json!({
99                        "error": format!("Failed to query tier_defaults: {}", e),
100                    })),
101                )
102                    .into_response();
103            }
104        };
105
106    let previous_tier = {
107        let handler_clone = db_handler.clone();
108        tokio::task::spawn_blocking(move || handler_clone.get_user_tier_sync(&wallet)).await
109    };
110    let previous_tier = match previous_tier {
111        Ok(Ok(tier)) => tier,
112        Ok(Err(e)) => {
113            return (
114                StatusCode::INTERNAL_SERVER_ERROR,
115                SonicJson(json!({
116                    "error": format!("Failed to load current user tier: {}", e),
117                })),
118            )
119                .into_response();
120        }
121        Err(e) => {
122            return (
123                StatusCode::INTERNAL_SERVER_ERROR,
124                SonicJson(json!({
125                    "error": format!("Failed to load current user tier: {}", e),
126                })),
127            )
128                .into_response();
129        }
130    };
131
132    // Build tier update with explicit limits from tier_defaults.
133    let new_tier = UserTierUpdate {
134        wallet_address: wallet,
135        tier: request.tier.clone(),
136        margin_mode: None,
137        version: None,
138        max_open_orders: Some(tier_defaults.max_open_orders),
139        max_open_positions: Some(tier_defaults.max_open_positions),
140        orders_per_minute: Some(tier_defaults.orders_per_minute),
141        cancels_per_minute: Some(tier_defaults.cancels_per_minute),
142        api_requests_per_minute: Some(tier_defaults.api_requests_per_minute),
143    };
144
145    match app_state.tier_cache.set_tier(new_tier).await {
146        Ok(()) => {
147            if let Err(e) = app_state.submit_tier_update_command(wallet).await {
148                tracing::error!(
149                    wallet = %wallet,
150                    error = %e,
151                    "Failed to apply monitoring tier update in engine"
152                );
153                let rollback_result = app_state
154                    .tier_cache
155                    .restore_tier_record(&wallet, previous_tier.as_ref())
156                    .await;
157                if let Err(rollback_err) = rollback_result {
158                    tracing::error!(
159                        wallet = %wallet,
160                        error = %rollback_err,
161                        "Failed to roll back tier after engine apply failure"
162                    );
163                }
164                return (
165                    StatusCode::INTERNAL_SERVER_ERROR,
166                    SonicJson(json!({
167                        "error": "Failed to apply tier update in engine",
168                    })),
169                )
170                    .into_response();
171            }
172            tracing::info!(
173                wallet = %wallet,
174                tier = %request.tier,
175                max_open_orders = tier_defaults.max_open_orders,
176                "User tier set via admin endpoint"
177            );
178            (
179                StatusCode::OK,
180                SonicJson(json!({
181                    "success": true,
182                    "wallet": wallet.to_string(),
183                    "tier": request.tier,
184                    "max_open_orders": tier_defaults.max_open_orders,
185                    "max_open_positions": tier_defaults.max_open_positions,
186                    "orders_per_minute": tier_defaults.orders_per_minute,
187                    "cancels_per_minute": tier_defaults.cancels_per_minute,
188                    "api_requests_per_minute": tier_defaults.api_requests_per_minute,
189                })),
190            )
191                .into_response()
192        }
193        Err(e) => {
194            tracing::error!(wallet = %wallet, error = %e, "Failed to set user tier");
195            (
196                StatusCode::INTERNAL_SERVER_ERROR,
197                SonicJson(json!({
198                    "error": format!("Failed to set tier: {}", e),
199                })),
200            )
201                .into_response()
202        }
203    }
204}