Skip to main content

hypercall_api/handlers/
account.rs

1use std::time::Instant;
2
3use alloy::primitives::U256;
4use rust_decimal::Decimal;
5use rust_decimal_macros::dec;
6use std::str::FromStr;
7use utoipa::IntoParams;
8
9use crate::sonic_json::SonicJson;
10use crate::{
11    error::ApiError,
12    middleware::SignerContext,
13    models::{
14        ApiResponse, FillApiResponse, FillsResponse, Order, OrdersResponse, Pagination, Portfolio,
15        RiskGridResponse, RiskGridScenario, TradeApiResponse, TradesResponse,
16    },
17};
18use axum::{
19    body::Body,
20    extract::{Query, State},
21    http::{header, StatusCode},
22    response::{IntoResponse, Response},
23};
24use hypercall_db::{AnalyticsReader, LiquidationReader};
25use hypercall_types::position_metrics::{enrich_position_metrics, PositionMarginMetrics};
26use hypercall_types::WalletAddress;
27use serde::{Deserialize, Serialize};
28use tracing::error;
29
30use super::{
31    api_error_response, api_json_response, convert_extended_risk_matrix,
32    convert_risk_grid_scenarios, normalize_orders_status_filter_input, pm_unavailable_response,
33    AppRuntimeConfig, AppState,
34};
35
36// ---------------------------------------------------------------------------
37
38#[derive(Debug, Deserialize, IntoParams)]
39pub struct TradesQuery {
40    /// Maximum number of trades to return (default: 100, max: 1000)
41    #[param(maximum = 1000)]
42    limit: Option<usize>,
43    /// Pagination offset
44    offset: Option<usize>,
45    /// Filter by specific option symbol
46    symbol: Option<String>,
47    /// Filter by underlying asset (e.g., "BTC", "ETH")
48    underlying: Option<String>,
49    /// Filter by wallet address, matching maker or taker.
50    account: Option<WalletAddress>,
51}
52
53#[derive(Debug, Deserialize, IntoParams)]
54pub struct PortfolioQuery {
55    /// Wallet address to get portfolio for
56    #[param(example = "0x1234567890abcdef1234567890abcdef12345678")]
57    pub wallet: WalletAddress,
58}
59
60fn portfolio_margin_mode_allowed(config: &AppRuntimeConfig, wallet: &WalletAddress) -> bool {
61    config.testnet_mode || config.portfolio_margin_mode_allowlist.contains(wallet)
62}
63
64#[derive(Debug, Deserialize)]
65pub struct WithdrawOptionRequest {
66    pub wallet: WalletAddress,
67    pub account: WalletAddress,
68    pub symbol: String,
69    pub amount: String,
70    pub nonce: u64,
71    pub signature: String,
72}
73
74#[derive(Debug, Deserialize)]
75pub struct WithdrawUsdcRequest {
76    pub wallet: WalletAddress,
77    pub account: WalletAddress,
78    pub destination: WalletAddress,
79    pub amount: String,
80    pub nonce: u64,
81    pub signature: String,
82}
83
84#[derive(Debug, Serialize)]
85pub struct WithdrawOptionResponse {
86    pub success: bool,
87    pub request_id: String,
88    pub directive_id: String,
89    pub domain_status: String,
90    pub delivery_status: String,
91    pub message: String,
92}
93
94#[derive(Debug, Serialize)]
95pub struct WithdrawUsdcResponse {
96    pub success: bool,
97    pub request_id: String,
98    pub directive_id: String,
99    pub domain_status: String,
100    pub delivery_status: String,
101    pub balance_after: Decimal,
102    pub message: String,
103}
104
105async fn ensure_cash_withdrawal_safety(
106    state: &AppState,
107    wallet: &WalletAddress,
108    amount: Decimal,
109) -> Result<(), ApiError> {
110    let snapshot = state
111        .portfolio_cache
112        .compute_wallet_margin_snapshot(wallet)
113        .await
114        .map_err(|error| {
115            error!(
116                "Failed to compute margin snapshot for USDC withdrawal {}: {}",
117                wallet, error
118            );
119            ApiError::internal_error("failed to validate withdrawal margin")
120        })?;
121
122    let post_withdraw_equity = snapshot.margin_summary.equity - amount;
123    let maintenance_required = snapshot.span_margin.maintenance_margin_required;
124    if post_withdraw_equity < maintenance_required {
125        return Err(ApiError::bad_request(format!(
126            "withdrawal would put account below maintenance margin: post_withdraw_equity={}, maintenance_required={}",
127            post_withdraw_equity, maintenance_required
128        )));
129    }
130
131    if let Some(record) = LiquidationReader::get_liquidation_state(state.db.as_ref(), wallet)
132        .await
133        .map_err(|error| {
134            error!(
135                "Failed to load liquidation state for USDC withdrawal {}: {}",
136                wallet, error
137            );
138            ApiError::internal_error("failed to validate withdrawal liquidation state")
139        })?
140    {
141        if matches!(
142            record.state.as_str(),
143            hypercall_types::liquidation_state::state_str::PRE_LIQUIDATION
144                | hypercall_types::liquidation_state::state_str::IN_LIQUIDATION
145        ) {
146            return Err(ApiError::bad_request(
147                "withdrawals are disabled while the account is in liquidation",
148            ));
149        }
150    }
151
152    let exchange_pool_balance = state
153        .exchange_pool_liquidity_reader
154        .usdc_pool_balance()
155        .await?;
156    if exchange_pool_balance < amount {
157        return Err(ApiError::bad_request(format!(
158            "insufficient pool liquidity, please try again later: available={}, requested={}",
159            exchange_pool_balance, amount
160        )));
161    }
162
163    Ok(())
164}
165
166async fn cash_withdrawal_request_already_journaled(
167    state: &AppState,
168    request_id: &str,
169) -> Result<bool, ApiError> {
170    state
171        .db
172        .directive_outbox_exists(request_id)
173        .await
174        .map_err(|error| {
175            error!(
176                "Failed to query directive outbox for USDC withdrawal retry: {}",
177                error
178            );
179            ApiError::internal_error("failed to validate withdrawal idempotency")
180        })
181}
182/// Get a list of recent trades
183#[utoipa::path(
184    get,
185    path = "/trades",
186    params(TradesQuery),
187    responses(
188        (status = 200, description = "List of trades", body = TradesResponse),
189        (status = 500, description = "Internal server error")
190    ),
191    tag = "Trading"
192)]
193pub async fn get_trades(
194    State(app_state): State<AppState>,
195    Query(params): Query<TradesQuery>,
196) -> Result<SonicJson<TradesResponse>, StatusCode> {
197    let limit = params.limit.unwrap_or(100).min(1000);
198    let offset = params.offset.unwrap_or(0);
199
200    let records = if let Some(symbol) = params.symbol {
201        AnalyticsReader::get_trades_by_option(app_state.db.as_ref(), &symbol, limit)
202            .await
203            .map_err(|e| {
204                tracing::error!("Failed to get trades by symbol {}: {}", symbol, e);
205                StatusCode::INTERNAL_SERVER_ERROR
206            })?
207    } else if let Some(underlying) = params.underlying {
208        AnalyticsReader::get_trades_by_underlying(app_state.db.as_ref(), &underlying, limit, offset)
209            .await
210            .map_err(|e| {
211                tracing::error!("Failed to get trades by underlying {}: {}", underlying, e);
212                StatusCode::INTERNAL_SERVER_ERROR
213            })?
214    } else if let Some(account) = params.account {
215        AnalyticsReader::get_trades_by_account(app_state.db.as_ref(), &account, limit, offset)
216            .await
217            .map_err(|e| {
218                tracing::error!("Failed to get trades by account {}: {}", account, e);
219                StatusCode::INTERNAL_SERVER_ERROR
220            })?
221    } else {
222        AnalyticsReader::get_all_trades(app_state.db.as_ref(), limit, offset)
223            .await
224            .map_err(|e| {
225                tracing::error!("Failed to get all trades: {}", e);
226                StatusCode::INTERNAL_SERVER_ERROR
227            })?
228    };
229
230    let count = records.len();
231    tracing::info!("Returning {} trades", count);
232
233    // Convert trades to API response format (with human-readable sizes)
234    let api_trades: Vec<TradeApiResponse> = records.into_iter().map(Into::into).collect();
235
236    Ok(SonicJson(TradesResponse {
237        success: true,
238        data: api_trades,
239        pagination: Pagination {
240            limit,
241            offset,
242            count,
243        },
244    }))
245}
246
247/// Get portfolio for a wallet
248#[utoipa::path(
249    get,
250    path = "/portfolio",
251    params(PortfolioQuery),
252    responses(
253        (status = 200, description = "Portfolio data", body = Portfolio),
254        (status = 503, description = "Portfolio margin data unavailable", body = ApiResponse<Portfolio>),
255        (status = 500, description = "Internal server error")
256    ),
257    security(("wallet_query" = [])),
258    tag = "Portfolio"
259)]
260pub async fn get_portfolio(
261    State(app_state): State<AppState>,
262    Query(params): Query<PortfolioQuery>,
263) -> Response {
264    let wallet = params.wallet;
265    tracing::info!("GET /portfolio request for wallet: {}", wallet);
266    let margin_mode = match app_state.tier_cache.get_margin_mode(&wallet).await {
267        Ok(mode) => mode,
268        Err(_) => {
269            return api_json_response(StatusCode::OK, ApiResponse::<Portfolio>::success_empty());
270        }
271    };
272
273    let mut portfolio = if matches!(margin_mode, hypercall_types::MarginMode::Portfolio) {
274        match app_state
275            .portfolio_cache
276            .get_portfolio_fail_closed_pm(&wallet)
277            .await
278        {
279            Ok(portfolio) => portfolio,
280            Err(error) => {
281                return pm_unavailable_response(format!(
282                    "Portfolio margin data unavailable for {}: {}",
283                    wallet, error
284                ));
285            }
286        }
287    } else {
288        match app_state.portfolio_cache.get_portfolio(&wallet).await {
289            Ok(p) => p,
290            Err(error) => {
291                tracing::error!(
292                    "Failed to fetch standard portfolio for {}: {}",
293                    wallet,
294                    error
295                );
296                return api_error_response(
297                    StatusCode::INTERNAL_SERVER_ERROR,
298                    "Failed to fetch portfolio".to_string(),
299                );
300            }
301        }
302    };
303
304    match app_state
305        .portfolio_cache
306        .compute_wallet_margin_snapshot(&wallet)
307        .await
308    {
309        Ok(margin_snapshot) => {
310            let mode = margin_snapshot.mode;
311            portfolio.margin_mode = margin_snapshot.mode.as_str().to_string();
312            portfolio.margin_summary = Some(margin_snapshot.margin_summary);
313            portfolio.span_margin = Some(margin_snapshot.span_margin);
314            portfolio.available_balance = margin_snapshot.available_balance;
315            portfolio.total_margin_used = margin_snapshot.total_margin_used;
316
317            if let Err(error) = enrich_position_metrics(
318                mode,
319                margin_snapshot
320                    .standard_position_contributions
321                    .map(|contributions| {
322                        contributions
323                            .into_iter()
324                            .map(|(symbol, contribution)| {
325                                (
326                                    symbol,
327                                    PositionMarginMetrics {
328                                        initial_margin: contribution.initial_margin,
329                                        maintenance_margin: contribution.maintenance_margin,
330                                    },
331                                )
332                            })
333                            .collect()
334                    }),
335                margin_snapshot.standard_option_marks,
336                &mut portfolio,
337            ) {
338                if matches!(mode, hypercall_types::MarginMode::Portfolio) {
339                    return pm_unavailable_response(format!(
340                        "Portfolio margin data unavailable for {}: {}",
341                        wallet, error
342                    ));
343                }
344                return api_error_response(StatusCode::INTERNAL_SERVER_ERROR, error.to_string());
345            }
346        }
347        Err(error) => {
348            if matches!(margin_mode, hypercall_types::MarginMode::Portfolio) {
349                return pm_unavailable_response(format!(
350                    "Portfolio margin data unavailable for {}: {}",
351                    wallet, error
352                ));
353            }
354
355            tracing::warn!(
356                "Margin snapshot unavailable for {} (serving portfolio without margin summary): {}",
357                wallet,
358                error
359            );
360            portfolio.margin_mode = margin_mode.as_str().to_string();
361            portfolio.margin_summary = None;
362            portfolio.span_margin = None;
363        }
364    }
365
366    api_json_response(StatusCode::OK, ApiResponse::success(portfolio))
367}
368
369pub async fn withdraw_option(
370    State(state): State<AppState>,
371    signer_ctx: SignerContext,
372    SonicJson(request): SonicJson<WithdrawOptionRequest>,
373) -> Result<SonicJson<WithdrawOptionResponse>, ApiError> {
374    if testnet_withdrawals_disabled(state.runtime_config.testnet_mode) {
375        return Err(ApiError::bad_request(
376            "withdrawals are not supported in testnet mode",
377        ));
378    }
379    if request.wallet != signer_ctx.wallet_address {
380        return Err(ApiError::forbidden(
381            "Wallet mismatch: signer does not match request wallet",
382        ));
383    }
384
385    let manager = state
386        .chain_auth
387        .get_manager(request.account.inner())
388        .await?;
389    if manager == alloy::primitives::Address::ZERO {
390        return Err(ApiError::unknown_account(format!(
391            "Unknown account: {}",
392            request.account
393        )));
394    }
395    if manager != request.wallet.inner() {
396        return Err(ApiError::forbidden(
397            "Account manager does not match request wallet",
398        ));
399    }
400
401    let quantity = Decimal::from_str(&request.amount)
402        .map_err(|_| ApiError::bad_request("amount must be a decimal string"))?;
403    if quantity <= Decimal::ZERO {
404        return Err(ApiError::bad_request("amount must be positive"));
405    }
406    let canonical_amount = canonical_decimal_string(quantity);
407
408    let instrument = state
409        .instruments_cache
410        .get_by_symbol(&request.symbol)
411        .await
412        .ok_or_else(|| ApiError::bad_request("unknown option symbol"))?;
413    let directive = option_withdrawal_directive(&instrument, quantity)?;
414    let request_id = option_withdrawal_request_id(
415        &request.wallet,
416        &request.account,
417        &request.symbol,
418        &canonical_amount,
419        request.nonce,
420    );
421    let action_bytes = hypercall_runtime_api::encode_credit_option_action(directive)
422        .map_err(|error| ApiError::bad_request(format!("invalid withdrawal: {}", error)))?;
423    let rsm_signer = rsm_signer_address_from_state(&state).await?;
424
425    let sender = state.option_withdrawal_sender.clone().ok_or_else(|| {
426        ApiError::new(
427            StatusCode::SERVICE_UNAVAILABLE,
428            "service_unavailable",
429            "option withdrawal engine path disabled",
430        )
431    })?;
432    let (applied_tx, applied_rx) = tokio::sync::oneshot::channel();
433    sender
434        .send(hypercall_runtime_api::OptionWithdrawalRequest {
435            request_id: request_id.clone(),
436            wallet: request.wallet,
437            account: request.account,
438            signer: signer_ctx.signer_address,
439            rsm_signer,
440            symbol: request.symbol.clone(),
441            quantity,
442            nonce: request.nonce,
443            action: action_bytes,
444            timestamp_ms: hypercall_types::utils::get_timestamp_millis(),
445            applied_tx: Some(applied_tx),
446        })
447        .await
448        .map_err(|_| {
449            ApiError::new(
450                StatusCode::SERVICE_UNAVAILABLE,
451                "service_unavailable",
452                "option withdrawal engine path closed",
453            )
454        })?;
455    let receipt = applied_rx
456        .await
457        .map_err(|_| {
458            ApiError::new(
459                StatusCode::SERVICE_UNAVAILABLE,
460                "service_unavailable",
461                "option withdrawal apply dropped",
462            )
463        })?
464        .map_err(ApiError::bad_request)?;
465
466    Ok(SonicJson(WithdrawOptionResponse {
467        success: true,
468        request_id,
469        directive_id: receipt.directive_id,
470        domain_status: receipt.domain_status.as_str().to_string(),
471        delivery_status: receipt.delivery_status.as_str().to_string(),
472        message: "Option withdrawal accepted".to_string(),
473    }))
474}
475
476pub async fn withdraw_usdc(
477    State(state): State<AppState>,
478    signer_ctx: SignerContext,
479    SonicJson(request): SonicJson<WithdrawUsdcRequest>,
480) -> Result<SonicJson<WithdrawUsdcResponse>, ApiError> {
481    if testnet_withdrawals_disabled(state.runtime_config.testnet_mode) {
482        return Err(ApiError::bad_request(
483            "withdrawals are not supported in testnet mode",
484        ));
485    }
486    if request.wallet != signer_ctx.wallet_address {
487        return Err(ApiError::forbidden(
488            "Wallet mismatch: signer does not match request wallet",
489        ));
490    }
491
492    let margin_mode = state
493        .tier_cache
494        .get_margin_mode(&request.wallet)
495        .await
496        .map_err(|_| ApiError::internal_error("failed to determine margin mode for withdrawal"))?;
497    if !matches!(margin_mode, hypercall_types::MarginMode::Standard) {
498        return Err(ApiError::bad_request(
499            "USDC withdrawals are only supported for standard margin accounts",
500        ));
501    }
502    let now_ms = hypercall_types::utils::get_timestamp_millis();
503    if !hypercall_engine::nonce_within_time_bounds(request.nonce, now_ms) {
504        return Err(ApiError::bad_request(format!(
505            "nonce {} is outside acceptable time bounds",
506            request.nonce
507        )));
508    }
509
510    // Validate destination before reserving balance so that an invalid
511    // destination (e.g. zero address) cannot leave funds permanently locked
512    // in a reserve with no matching outbox row.
513    if request.destination == WalletAddress::default() {
514        return Err(ApiError::bad_request(
515            "withdrawal destination must not be the zero address",
516        ));
517    }
518    if request.account == WalletAddress::default() {
519        return Err(ApiError::bad_request(
520            "withdrawal account must not be the zero address",
521        ));
522    }
523    if request.account != request.wallet {
524        return Err(ApiError::bad_request(
525            "standard margin USDC withdrawal account must equal wallet",
526        ));
527    }
528
529    let amount = Decimal::from_str(&request.amount)
530        .map_err(|_| ApiError::bad_request("amount must be a decimal string"))?;
531    if amount <= Decimal::ZERO {
532        return Err(ApiError::bad_request("amount must be positive"));
533    }
534    let canonical_amount = canonical_decimal_string(amount);
535    let request_id = usdc_withdrawal_request_id(
536        &request.wallet,
537        &request.account,
538        &request.destination,
539        &canonical_amount,
540        request.nonce,
541    );
542    if !cash_withdrawal_request_already_journaled(&state, &request_id).await? {
543        ensure_cash_withdrawal_safety(&state, &request.wallet, amount).await?;
544    }
545    let amount_wei = usdc_amount_to_token_amount(amount)?;
546    let timestamp_ms = hypercall_types::utils::get_timestamp_millis();
547    let rsm_signer = rsm_signer_address_from_state(&state).await?;
548
549    let sender = state.cash_withdrawal_sender.clone().ok_or_else(|| {
550        ApiError::new(
551            StatusCode::SERVICE_UNAVAILABLE,
552            "service_unavailable",
553            "cash withdrawal engine path disabled",
554        )
555    })?;
556    let (applied_tx, applied_rx) = tokio::sync::oneshot::channel();
557    sender
558        .send(hypercall_runtime_api::CashWithdrawalRequest {
559            request_id: request_id.clone(),
560            wallet: request.wallet,
561            account: request.account,
562            destination: request.destination,
563            signer: signer_ctx.signer_address,
564            rsm_signer,
565            amount,
566            amount_wei,
567            nonce: request.nonce,
568            timestamp_ms,
569            applied_tx: Some(applied_tx),
570        })
571        .await
572        .map_err(|_| {
573            ApiError::new(
574                StatusCode::SERVICE_UNAVAILABLE,
575                "service_unavailable",
576                "cash withdrawal engine path closed",
577            )
578        })?;
579    let receipt = applied_rx
580        .await
581        .map_err(|_| {
582            ApiError::new(
583                StatusCode::SERVICE_UNAVAILABLE,
584                "service_unavailable",
585                "cash withdrawal apply dropped",
586            )
587        })?
588        .map_err(ApiError::bad_request)?;
589
590    Ok(SonicJson(WithdrawUsdcResponse {
591        success: true,
592        request_id: request_id.clone(),
593        directive_id: receipt.directive_id,
594        domain_status: receipt.domain_status.as_str().to_string(),
595        delivery_status: receipt.delivery_status.as_str().to_string(),
596        balance_after: receipt.balance_after,
597        message: "USDC withdrawal accepted".to_string(),
598    }))
599}
600
601fn testnet_withdrawals_disabled(testnet_mode: bool) -> bool {
602    if !testnet_mode {
603        return false;
604    }
605
606    #[cfg(feature = "test-endpoints")]
607    {
608        if std::env::var("ALLOW_TESTNET_WITHDRAWALS").as_deref() == Ok("1") {
609            return false;
610        }
611    }
612
613    true
614}
615
616fn option_withdrawal_directive(
617    instrument: &hypercall_types::Instrument,
618    quantity: Decimal,
619) -> Result<hypercall_runtime_api::SystemCreditOptionDirective, ApiError> {
620    let option_type = instrument
621        .option_type
622        .parse::<hypercall_types::OptionType>()
623        .map_err(|_| ApiError::bad_request("invalid option_type for instrument"))?;
624    let strike_e8 = hypercall_types::utils::strike_to_e8(instrument.strike)
625        .map_err(|error| ApiError::bad_request(format!("invalid strike: {}", error)))?;
626    let underlying =
627        hypercall_types::option_token_address::encode_short_string_bytes32(&instrument.underlying)
628            .map_err(|error| ApiError::bad_request(format!("invalid underlying: {}", error)))?;
629    let expiry_ts = option_expiry_to_timestamp(&instrument.underlying, instrument.expiry)
630        .map_err(|error| ApiError::bad_request(format!("invalid expiry: {}", error)))?;
631
632    Ok(hypercall_runtime_api::SystemCreditOptionDirective {
633        underlying: underlying.into(),
634        expiry: U256::from(expiry_ts),
635        strike: U256::from(strike_e8),
636        is_call: option_type.is_call(),
637        amount_wei: option_quantity_to_token_amount(quantity)?,
638    })
639}
640
641fn option_expiry_to_timestamp(underlying: &str, expiry: u64) -> Result<u64, String> {
642    if expiry < 100_000_000 {
643        hypercall_types::utils::expiry_date_to_timestamp_checked(underlying, expiry)
644            .map_err(|error| error.to_string())
645    } else {
646        Ok(expiry)
647    }
648}
649
650fn option_quantity_to_token_amount(quantity: Decimal) -> Result<U256, ApiError> {
651    let scaled = quantity * dec!(1000000);
652    if scaled.fract() != Decimal::ZERO {
653        return Err(ApiError::bad_request(
654            "amount must have at most 6 decimal places",
655        ));
656    }
657    U256::from_str(&scaled.normalize().to_string())
658        .map_err(|_| ApiError::bad_request("amount is too large for uint256"))
659}
660
661fn usdc_amount_to_token_amount(amount: Decimal) -> Result<u64, ApiError> {
662    // HyperCore CoreWriter sendAsset uses 1e8 scaling for all token amounts,
663    // regardless of the ERC20's native decimals (USDC ERC20 = 6 decimals,
664    // but CoreWriter amountWei = amount * 1e8).
665    let scaled = amount * dec!(100000000);
666    if scaled.fract() != Decimal::ZERO {
667        return Err(ApiError::bad_request(
668            "amount must have at most 8 decimal places",
669        ));
670    }
671    u64::from_str(&scaled.normalize().to_string())
672        .map_err(|_| ApiError::bad_request("amount is too large for uint64"))
673}
674
675fn canonical_decimal_string(amount: Decimal) -> String {
676    amount.normalize().to_string()
677}
678
679fn option_withdrawal_request_id(
680    wallet: &WalletAddress,
681    account: &WalletAddress,
682    symbol: &str,
683    amount: &str,
684    nonce: u64,
685) -> String {
686    let mut material = Vec::with_capacity(20 + symbol.len() + amount.len() + 32);
687    material.extend_from_slice(b"hypercall:withdraw-option:v1");
688    material.extend_from_slice(wallet.as_bytes());
689    material.extend_from_slice(account.as_bytes());
690    material.extend_from_slice(symbol.as_bytes());
691    material.push(0);
692    material.extend_from_slice(amount.as_bytes());
693    material.push(0);
694    material.extend_from_slice(&nonce.to_be_bytes());
695
696    let hash = alloy::primitives::keccak256(material);
697    let mut bytes = [0_u8; 16];
698    bytes.copy_from_slice(&hash[..16]);
699    bytes[6] = (bytes[6] & 0x0f) | 0x80;
700    bytes[8] = (bytes[8] & 0x3f) | 0x80;
701    uuid::Uuid::from_bytes(bytes).to_string()
702}
703
704async fn rsm_signer_address_from_state(state: &AppState) -> Result<WalletAddress, ApiError> {
705    if let Some(address) = state.rsm_signer_address {
706        return Ok(address);
707    }
708
709    let signer = state.rsm_signer.as_ref().ok_or_else(|| {
710        ApiError::new(
711            StatusCode::SERVICE_UNAVAILABLE,
712            "service_unavailable",
713            "RSM signer is not configured",
714        )
715    })?;
716    signer
717        .status()
718        .await
719        .map(|status| status.signer)
720        .map_err(|error| {
721            ApiError::new(
722                StatusCode::SERVICE_UNAVAILABLE,
723                "service_unavailable",
724                format!("RSM signer is not available: {error}"),
725            )
726        })
727}
728
729fn usdc_withdrawal_request_id(
730    wallet: &WalletAddress,
731    account: &WalletAddress,
732    destination: &WalletAddress,
733    amount: &str,
734    nonce: u64,
735) -> String {
736    let mut material = Vec::with_capacity(20 * 3 + amount.len() + 40);
737    material.extend_from_slice(b"hypercall:withdraw-usdc:v1");
738    material.extend_from_slice(wallet.as_bytes());
739    material.extend_from_slice(account.as_bytes());
740    material.extend_from_slice(destination.as_bytes());
741    material.push(0);
742    material.extend_from_slice(amount.as_bytes());
743    material.push(0);
744    material.extend_from_slice(&nonce.to_be_bytes());
745
746    let hash = alloy::primitives::keccak256(material);
747    let mut bytes = [0_u8; 16];
748    bytes.copy_from_slice(&hash[..16]);
749    bytes[6] = (bytes[6] & 0x0f) | 0x80;
750    bytes[8] = (bytes[8] & 0x3f) | 0x80;
751    uuid::Uuid::from_bytes(bytes).to_string()
752}
753
754#[cfg(test)]
755mod portfolio_liquidation_tests {
756    use super::*;
757    use crate::models::{Portfolio, Position, PositionWithMetrics, SpanMarginSummary};
758    use chrono::Utc;
759    use std::collections::HashMap;
760
761    fn test_wallet(id: u8) -> WalletAddress {
762        let mut bytes = [0u8; 20];
763        bytes[19] = id;
764        WalletAddress::from(bytes)
765    }
766
767    fn test_runtime_config(
768        testnet_mode: bool,
769        portfolio_margin_mode_allowlist: Vec<WalletAddress>,
770    ) -> AppRuntimeConfig {
771        AppRuntimeConfig {
772            testnet_mode,
773            trade_explorer_url_template: None,
774            wal_path: std::path::PathBuf::from("/tmp/account-handler-test.wal"),
775            db_host: "localhost".to_string(),
776            db_name: "hypercall_test".to_string(),
777            directive_chain_id: hypercall_types::directives::HYPERCALL_MAINNET_CHAIN_ID,
778            signing_chain_id: hypercall_types::directives::HYPERCALL_MAINNET_CHAIN_ID,
779            exchange_contract_address: "0x0000000000000000000000000000000000000000".to_string(),
780            portfolio_margin_pool_enabled: false,
781            portfolio_margin_mode_allowlist,
782            portfolio_margin_settlement_allowlist: Vec::new(),
783            #[cfg(feature = "rsm-state")]
784            rsm_environment: hypercall_db::ValidatorRsmEnvironment::Testnet,
785        }
786    }
787
788    #[test]
789    fn portfolio_margin_mode_allowed_in_testnet() {
790        let config = test_runtime_config(true, Vec::new());
791        assert!(portfolio_margin_mode_allowed(&config, &test_wallet(1)));
792    }
793
794    #[test]
795    fn portfolio_margin_mode_allowed_for_allowlisted_wallet_outside_testnet() {
796        let wallet = test_wallet(1);
797        let config = test_runtime_config(false, vec![wallet]);
798        assert!(portfolio_margin_mode_allowed(&config, &wallet));
799    }
800
801    #[test]
802    fn portfolio_margin_mode_rejected_for_unlisted_wallet_outside_testnet() {
803        let config = test_runtime_config(false, vec![test_wallet(1)]);
804        assert!(!portfolio_margin_mode_allowed(&config, &test_wallet(2)));
805    }
806
807    fn test_position(
808        symbol: &str,
809        amount: Decimal,
810        entry: Decimal,
811        upnl: Decimal,
812    ) -> PositionWithMetrics {
813        PositionWithMetrics {
814            position: Position {
815                wallet_address: test_wallet(1),
816                symbol: symbol.to_string(),
817                amount,
818                entry_price: entry,
819                margin_posted: dec!(0),
820                realized_pnl: dec!(0),
821                unrealized_pnl: upnl,
822                updated_at: Utc::now(),
823            },
824            notional_value: amount * entry,
825            maintenance_margin: dec!(0),
826            liquidation_price: dec!(0),
827            margin_ratio: dec!(0),
828        }
829    }
830
831    fn base_portfolio(positions: Vec<PositionWithMetrics>) -> Portfolio {
832        Portfolio {
833            wallet_address: test_wallet(1),
834            positions,
835            total_margin_used: dec!(0),
836            available_balance: dec!(0),
837            span_margin: Some(SpanMarginSummary {
838                equity: dec!(10000),
839                initial_margin_required: dec!(0),
840                maintenance_margin_required: dec!(9000),
841                open_orders_initial_margin: dec!(0),
842                option_margin_required: dec!(0),
843                scanning_risk: dec!(0),
844                option_floor: dec!(0),
845                gamma_overlay: dec!(0),
846                hypercore_margin_required: dec!(0),
847            }),
848            margin_mode: "standard".to_string(),
849            margin_summary: None,
850        }
851    }
852
853    #[test]
854    fn test_compute_short_option_liquidation_mark() {
855        let liq = hypercall_types::position_metrics::compute_short_option_liquidation_mark(
856            dec!(10000),
857            dec!(9000),
858            dec!(-2),
859            dec!(100),
860        )
861        .expect("liq mark should compute");
862        assert_eq!(liq, dec!(600));
863    }
864
865    #[test]
866    fn test_compute_short_option_liquidation_mark_already_liquidating() {
867        let liq = hypercall_types::position_metrics::compute_short_option_liquidation_mark(
868            dec!(8000),
869            dec!(9000),
870            dec!(-1),
871            dec!(50),
872        )
873        .expect("liq mark should compute");
874        assert_eq!(liq, dec!(50));
875    }
876
877    #[test]
878    fn test_enrich_position_metrics_standard_mode() {
879        let mut portfolio = base_portfolio(vec![
880            test_position("BTC-20260213-70000-C", dec!(-2), dec!(100), dec!(-20)),
881            test_position("BTC-20260213-80000-C", dec!(1), dec!(30), dec!(5)),
882        ]);
883
884        let mut contributions = HashMap::new();
885        contributions.insert(
886            "BTC-20260213-70000-C".to_string(),
887            hypercall_types::position_metrics::PositionMarginMetrics {
888                initial_margin: dec!(1500),
889                maintenance_margin: dec!(900),
890            },
891        );
892
893        let mut marks = HashMap::new();
894        marks.insert("BTC-20260213-70000-C".to_string(), dec!(110));
895        marks.insert("BTC-20260213-80000-C".to_string(), dec!(30));
896
897        enrich_position_metrics(
898            hypercall_types::MarginMode::Standard,
899            Some(contributions),
900            Some(marks),
901            &mut portfolio,
902        )
903        .expect("enrichment should succeed");
904
905        let short = portfolio
906            .positions
907            .iter()
908            .find(|p| p.position.symbol == "BTC-20260213-70000-C")
909            .expect("short position exists");
910        assert_eq!(short.position.margin_posted, dec!(1500));
911        assert_eq!(short.maintenance_margin, dec!(900));
912        assert!(short.liquidation_price > dec!(0));
913
914        let long = portfolio
915            .positions
916            .iter()
917            .find(|p| p.position.symbol == "BTC-20260213-80000-C")
918            .expect("long position exists");
919        assert_eq!(long.liquidation_price, dec!(0));
920    }
921
922    #[test]
923    fn test_enrich_position_metrics_portfolio_mode_uses_position_derived_mark() {
924        let mut portfolio = base_portfolio(vec![test_position(
925            "BTC-20260213-70000-C",
926            dec!(-2),
927            dec!(100),
928            dec!(-20),
929        )]);
930        if let Some(ref mut span) = portfolio.span_margin {
931            span.equity = dec!(9800);
932            span.maintenance_margin_required = dec!(9000);
933        }
934
935        enrich_position_metrics(
936            hypercall_types::MarginMode::Portfolio,
937            None,
938            None,
939            &mut portfolio,
940        )
941        .expect("enrichment should succeed");
942
943        let short = &portfolio.positions[0];
944        // Derived mark = entry + upnl/amount = 100 + (-20/-2) = 110
945        // liq = 110 + ((9000-9800)/-2) = 510
946        assert_eq!(short.liquidation_price, dec!(510));
947    }
948
949    #[test]
950    fn test_option_withdrawal_request_id_is_nonce_idempotent() {
951        let wallet = test_wallet(1);
952        let account = test_wallet(2);
953        let first =
954            option_withdrawal_request_id(&wallet, &account, "BTC-20260130-100000-C", "1.25", 42);
955        let retry =
956            option_withdrawal_request_id(&wallet, &account, "BTC-20260130-100000-C", "1.25", 42);
957        let different_nonce =
958            option_withdrawal_request_id(&wallet, &account, "BTC-20260130-100000-C", "1.25", 43);
959
960        assert_eq!(first, retry);
961        assert_ne!(first, different_nonce);
962        uuid::Uuid::parse_str(&first).expect("request id should be a UUID");
963    }
964
965    #[test]
966    fn test_option_withdrawal_request_id_uses_canonical_amount() {
967        let wallet = test_wallet(1);
968        let account = test_wallet(2);
969        let canonical = canonical_decimal_string(Decimal::from_str("1.000000").unwrap());
970        let retry = canonical_decimal_string(Decimal::from_str("01.0").unwrap());
971
972        assert_eq!(canonical, "1");
973        assert_eq!(retry, "1");
974
975        let first = option_withdrawal_request_id(
976            &wallet,
977            &account,
978            "BTC-20260130-100000-C",
979            &canonical,
980            42,
981        );
982        let second =
983            option_withdrawal_request_id(&wallet, &account, "BTC-20260130-100000-C", &retry, 42);
984        let different_nonce = option_withdrawal_request_id(
985            &wallet,
986            &account,
987            "BTC-20260130-100000-C",
988            &canonical,
989            43,
990        );
991
992        assert_eq!(first, second);
993        assert_ne!(first, different_nonce);
994        uuid::Uuid::parse_str(&first).expect("request id should be a UUID");
995    }
996
997    #[test]
998    fn test_usdc_withdrawal_request_id_uses_canonical_amount() {
999        let wallet = test_wallet(1);
1000        let account = test_wallet(2);
1001        let destination = test_wallet(3);
1002        let canonical = canonical_decimal_string(Decimal::from_str("1.00000000").unwrap());
1003        let retry = canonical_decimal_string(Decimal::from_str("01.0").unwrap());
1004
1005        assert_eq!(canonical, "1");
1006        assert_eq!(retry, "1");
1007
1008        let first = usdc_withdrawal_request_id(&wallet, &account, &destination, &canonical, 42);
1009        let second = usdc_withdrawal_request_id(&wallet, &account, &destination, &retry, 42);
1010        let different_nonce =
1011            usdc_withdrawal_request_id(&wallet, &account, &destination, &canonical, 43);
1012
1013        assert_eq!(first, second);
1014        assert_ne!(first, different_nonce);
1015        uuid::Uuid::parse_str(&first).expect("request id should be a UUID");
1016    }
1017
1018    #[test]
1019    fn test_parse_exchange_pool_balance_parser() {
1020        let json = serde_json::json!({
1021            "marginSummary": {
1022                "accountValue": "41316.993784",
1023                "totalNtlPos": "0.0",
1024                "totalRawUsd": "41316.993784",
1025                "totalMarginUsed": "0.0"
1026            },
1027            "withdrawable": "41316.993784",
1028            "assetPositions": [],
1029            "time": 1781200688450_u64
1030        });
1031
1032        assert_eq!(
1033            crate::state::parse_hypercore_withdrawable_balance(&json).unwrap(),
1034            dec!(41316.993784)
1035        );
1036    }
1037
1038    #[test]
1039    fn test_parse_exchange_pool_balance_allows_zero_withdrawable() {
1040        let json = serde_json::json!({
1041            "withdrawable": "0.0",
1042        });
1043
1044        assert_eq!(
1045            crate::state::parse_hypercore_withdrawable_balance(&json).unwrap(),
1046            Decimal::ZERO
1047        );
1048    }
1049
1050    #[test]
1051    fn test_parse_exchange_pool_balance_missing_withdrawable_errors() {
1052        let json = serde_json::json!({
1053            "marginSummary": {},
1054        });
1055
1056        assert!(crate::state::parse_hypercore_withdrawable_balance(&json).is_err());
1057    }
1058
1059    #[test]
1060    fn test_parse_exchange_pool_balance_invalid_withdrawable_errors() {
1061        let json = serde_json::json!({
1062            "withdrawable": "not-a-number",
1063        });
1064
1065        assert!(crate::state::parse_hypercore_withdrawable_balance(&json).is_err());
1066    }
1067
1068    #[test]
1069    fn test_option_quantity_to_token_amount_accepts_decimal_contracts() {
1070        assert_eq!(
1071            option_quantity_to_token_amount(dec!(1.25)).expect("valid option quantity"),
1072            U256::from(1_250_000_u64)
1073        );
1074        assert_eq!(
1075            option_quantity_to_token_amount(dec!(1.0)).expect("valid option quantity"),
1076            U256::from(1_000_000_u64)
1077        );
1078        assert!(option_quantity_to_token_amount(dec!(0.0000001)).is_err());
1079    }
1080
1081    #[test]
1082    fn test_option_expiry_to_timestamp_accepts_date_code_and_timestamp() {
1083        let converted =
1084            option_expiry_to_timestamp("BTC", 20260130).expect("date code should convert");
1085        assert!(converted > 1_700_000_000);
1086
1087        assert_eq!(
1088            option_expiry_to_timestamp("BTC", converted).expect("timestamp should pass through"),
1089            converted
1090        );
1091    }
1092}
1093
1094#[cfg(test)]
1095mod options_chain_handler_tests {
1096    use crate::handlers::options_chain::insert_options_chain_leg_for_strike;
1097    use crate::models::{OptionsChainGreeksAbs, OptionsChainLeg};
1098    use crate::options_chain::{
1099        apply_side_filter_to_leg, compute_cash_greeks, OptionsChainSideFilter,
1100    };
1101    use rust_decimal_macros::dec;
1102    use std::collections::BTreeMap;
1103
1104    fn test_leg(symbol: &str) -> OptionsChainLeg {
1105        OptionsChainLeg {
1106            symbol: symbol.to_string(),
1107            option_token_address: None,
1108            bid_price_usd: Some(100.0),
1109            bid_iv: Some(0.5),
1110            bid_size_contracts: Some(1.0),
1111            bid_size_usd_notional: Some(100.0),
1112            ask_price_usd: Some(101.0),
1113            ask_iv: Some(0.51),
1114            ask_size_contracts: Some(2.0),
1115            ask_size_usd_notional: Some(202.0),
1116            greeks_abs: None,
1117            greeks_cash: None,
1118        }
1119    }
1120
1121    #[test]
1122    fn test_insert_options_chain_leg_groups_call_and_put_same_strike() {
1123        let mut grouped = BTreeMap::new();
1124        insert_options_chain_leg_for_strike(
1125            &mut grouped,
1126            dec!(50000),
1127            "call",
1128            test_leg("BTC-20260331-50000-C"),
1129        )
1130        .expect("call should insert");
1131        insert_options_chain_leg_for_strike(
1132            &mut grouped,
1133            dec!(50000),
1134            "put",
1135            test_leg("BTC-20260331-50000-P"),
1136        )
1137        .expect("put should insert");
1138
1139        assert_eq!(grouped.len(), 1, "both legs should share one strike row");
1140        let row = grouped.get(&dec!(50000)).expect("strike row should exist");
1141        assert!(row.call.is_some(), "call leg should be present");
1142        assert!(row.put.is_some(), "put leg should be present");
1143    }
1144
1145    #[test]
1146    fn test_side_filter_buy_hides_bid_fields() {
1147        let mut leg = test_leg("BTC-20260331-50000-C");
1148        apply_side_filter_to_leg(&mut leg, OptionsChainSideFilter::Buy);
1149        assert!(leg.bid_price_usd.is_none());
1150        assert!(leg.bid_iv.is_none());
1151        assert!(leg.bid_size_contracts.is_none());
1152        assert!(leg.bid_size_usd_notional.is_none());
1153        assert!(leg.ask_price_usd.is_some());
1154        assert!(leg.ask_size_contracts.is_some());
1155    }
1156
1157    #[test]
1158    fn test_cash_greeks_formula_uses_pnl_style_convention() {
1159        let abs = OptionsChainGreeksAbs {
1160            delta: 0.4,
1161            gamma: 0.002,
1162            theta: -1.5,
1163            vega: 75.0,
1164        };
1165        let spot = 50_000.0;
1166        let cash = compute_cash_greeks(&abs, spot).expect("cash greeks should compute");
1167
1168        assert!((cash.delta_1pct_usd - 200.0).abs() < 1e-9);
1169        assert!((cash.gamma_1pct_usd - 250.0).abs() < 1e-9);
1170        assert!((cash.theta_1d_usd + 1.5).abs() < 1e-9);
1171        assert!((cash.vega_1vol_usd - 75.0).abs() < 1e-9);
1172    }
1173}
1174
1175#[derive(Debug, Deserialize, IntoParams)]
1176pub struct RiskGridQuery {
1177    /// Wallet address to get risk grid for
1178    #[param(example = "0x1234567890abcdef1234567890abcdef12345678")]
1179    pub wallet: WalletAddress,
1180}
1181
1182/// Get extended risk grid for a wallet.
1183///
1184/// Returns detailed margin breakdown and scenario P&L for risk analysis.
1185/// This is similar to Deribit's risk grid endpoint.
1186///
1187/// The response includes:
1188/// - `equity`: Executed PM equity = cash + executed option UPNL + executed perp UPNL
1189/// - `position_initial_margin`: IM from executed positions only
1190/// - `position_maintenance_margin`: MM threshold for liquidation
1191/// - `open_orders_initial_margin`: Additional IM from open orders
1192/// - `scenarios`: P&L under each SPAN scenario
1193#[utoipa::path(
1194    get,
1195    path = "/risk/grid",
1196    params(RiskGridQuery),
1197    responses(
1198        (status = 200, description = "Risk grid response", body = ApiResponse<RiskGridResponse>),
1199        (status = 503, description = "Portfolio margin data unavailable", body = ApiResponse<RiskGridResponse>)
1200    ),
1201    tag = "Risk"
1202)]
1203pub async fn get_risk_grid(
1204    State(app_state): State<AppState>,
1205    Query(params): Query<RiskGridQuery>,
1206) -> Response {
1207    let wallet = params.wallet;
1208    tracing::info!("GET /risk/grid request for wallet: {}", wallet);
1209
1210    // Risk grid (SPAN scenarios) is only available for portfolio margin accounts
1211    let margin_mode = match app_state.tier_cache.get_margin_mode(&wallet).await {
1212        Ok(mode) => mode,
1213        Err(error) => {
1214            return api_error_response(
1215                StatusCode::SERVICE_UNAVAILABLE,
1216                format!("Margin mode unavailable for {}: {}", wallet, error),
1217            );
1218        }
1219    };
1220    if matches!(margin_mode, hypercall_types::MarginMode::Standard) {
1221        return api_error_response(
1222            StatusCode::OK,
1223            "Risk grid is only available for portfolio margin accounts. This account uses standard margin.".to_string(),
1224        );
1225    }
1226
1227    let grid_data = match app_state
1228        .portfolio_cache
1229        .compute_pm_risk_grid_data(&wallet)
1230        .await
1231    {
1232        Ok(data) => data,
1233        Err(e) => {
1234            tracing::warn!("Failed to compute PM risk grid for {}: {}", wallet, e);
1235            return pm_unavailable_response(format!("Failed to compute PM risk grid: {}", e));
1236        }
1237    };
1238
1239    let position_im = grid_data.position_details.initial_margin_required;
1240    let open_orders_im =
1241        (grid_data.margin_details.initial_margin_required - position_im).max(Decimal::ZERO);
1242    let position_mm = grid_data.position_details.maintenance_margin_required;
1243
1244    let scenarios: Vec<RiskGridScenario> =
1245        match convert_risk_grid_scenarios(grid_data.scenario_pnls) {
1246            Ok(scenarios) => scenarios,
1247            Err(error) => {
1248                return pm_unavailable_response(format!("Failed to serialize risk grid: {}", error))
1249            }
1250        };
1251
1252    let extended_risk_matrix = match convert_extended_risk_matrix(grid_data.extended_grid) {
1253        Ok(m) => m,
1254        Err(error) => {
1255            return pm_unavailable_response(format!(
1256                "Failed to serialize extended risk grid: {}",
1257                error
1258            ))
1259        }
1260    };
1261
1262    api_json_response(
1263        StatusCode::OK,
1264        ApiResponse::success(RiskGridResponse {
1265            equity: grid_data.margin_details.equity,
1266            position_initial_margin: position_im,
1267            position_maintenance_margin: position_mm,
1268            open_orders_initial_margin: open_orders_im,
1269            total_initial_margin: position_im + open_orders_im,
1270            scanning_risk: grid_data.margin_details.scanning_risk,
1271            option_floor: grid_data.margin_details.option_floor,
1272            gamma_overlay: grid_data.margin_details.gamma_overlay,
1273            scenarios,
1274            extended_risk_matrix,
1275        }),
1276    )
1277}
1278
1279#[derive(Debug, Deserialize, IntoParams)]
1280pub struct FillsQuery {
1281    /// Wallet address to get fills for
1282    #[param(example = "0x1234567890abcdef1234567890abcdef12345678")]
1283    pub wallet: WalletAddress,
1284    /// Maximum number of fills to return (default: 100, max: 1000)
1285    #[param(maximum = 1000)]
1286    pub limit: Option<usize>,
1287    /// Pagination offset
1288    pub offset: Option<usize>,
1289}
1290
1291#[derive(Debug, Deserialize, IntoParams)]
1292pub struct OrdersQuery {
1293    /// Wallet address to get orders for
1294    #[param(example = "0x1234567890abcdef1234567890abcdef12345678")]
1295    pub wallet: WalletAddress,
1296    /// Filter by order status
1297    pub status: Option<String>,
1298    /// Maximum number of orders to return (default: 50, max: 50)
1299    #[param(maximum = 50)]
1300    pub limit: Option<usize>,
1301    /// Pagination offset
1302    pub offset: Option<usize>,
1303}
1304
1305#[derive(Debug, Deserialize, IntoParams)]
1306pub struct MarketsQuery {
1307    /// Whether to include individual instruments in the response. Defaults to true.
1308    #[param(example = "false")]
1309    include_instruments: Option<bool>,
1310}
1311
1312/// Get all available markets with their instruments
1313#[utoipa::path(
1314    get,
1315    path = "/markets",
1316    params(MarketsQuery),
1317    responses(
1318        (status = 200, description = "List of markets", body = MarketsResponse),
1319        (status = 500, description = "Internal server error")
1320    ),
1321    tag = "Markets"
1322)]
1323pub async fn get_markets(
1324    State(app_state): State<AppState>,
1325    Query(params): Query<MarketsQuery>,
1326) -> Response {
1327    let started = Instant::now();
1328    let include_instruments = params.include_instruments.unwrap_or(true);
1329    let (body, built_at) = if include_instruments {
1330        app_state.markets_snapshot_cache.response()
1331    } else {
1332        app_state.markets_snapshot_cache.response_slim()
1333    };
1334    let last_modified = httpdate::fmt_http_date(built_at);
1335    // Cache-Control max-age=1 matches the MarketsSnapshotCache refresh interval (1s).
1336    // The response is already a pre-built snapshot so it won't change faster than that;
1337    // this lets CDNs/proxies absorb repeated hits within the same refresh window.
1338    let response = (
1339        StatusCode::OK,
1340        [
1341            (header::CONTENT_TYPE, "application/json".to_string()),
1342            (header::LAST_MODIFIED, last_modified),
1343            (header::CACHE_CONTROL, "public, max-age=1".to_string()),
1344        ],
1345        Body::from(body),
1346    )
1347        .into_response();
1348    let elapsed = started.elapsed().as_secs_f64();
1349    metrics::histogram!("ht_markets_handler_seconds").record(elapsed);
1350    metrics::histogram!("ht_http_request_seconds", "endpoint" => "/markets").record(elapsed);
1351    response
1352}
1353
1354/// Get fills for a wallet
1355#[utoipa::path(
1356    get,
1357    path = "/fills",
1358    params(FillsQuery),
1359    responses(
1360        (status = 200, description = "List of fills", body = FillsResponse),
1361        (status = 500, description = "Internal server error")
1362    ),
1363    security(("wallet_query" = [])),
1364    tag = "Portfolio"
1365)]
1366pub async fn get_fills(
1367    State(app_state): State<AppState>,
1368    Query(params): Query<FillsQuery>,
1369) -> Result<SonicJson<FillsResponse>, StatusCode> {
1370    let limit = params.limit.unwrap_or(100).min(1000);
1371    let offset = params.offset.unwrap_or(0);
1372
1373    tracing::info!(
1374        "GET /fills request for wallet: {}, limit: {}, offset: {}",
1375        params.wallet,
1376        limit,
1377        offset
1378    );
1379
1380    match AnalyticsReader::get_fills_by_account(
1381        app_state.db.as_ref(),
1382        &params.wallet,
1383        limit,
1384        offset,
1385    )
1386    .await
1387    {
1388        Ok(records) => {
1389            let count = records.len();
1390            // Convert fills to API response format (with human-readable sizes)
1391            let api_fills: Vec<FillApiResponse> = records.into_iter().map(Into::into).collect();
1392            Ok(SonicJson(FillsResponse {
1393                success: true,
1394                data: api_fills,
1395                pagination: Pagination {
1396                    limit,
1397                    offset,
1398                    count,
1399                },
1400            }))
1401        }
1402        Err(e) => {
1403            error!("Failed to get fills: {}", e);
1404            Err(StatusCode::INTERNAL_SERVER_ERROR)
1405        }
1406    }
1407}
1408
1409/// Get orders for a wallet
1410#[utoipa::path(
1411    get,
1412    path = "/orders",
1413    params(OrdersQuery),
1414    responses(
1415        (status = 200, description = "List of orders", body = OrdersResponse),
1416        (status = 500, description = "Internal server error")
1417    ),
1418    security(("wallet_query" = [])),
1419    tag = "Trading"
1420)]
1421pub async fn get_orders(
1422    State(app_state): State<AppState>,
1423    Query(params): Query<OrdersQuery>,
1424) -> Result<SonicJson<OrdersResponse>, StatusCode> {
1425    let limit = params.limit.unwrap_or(50).min(200); // Default to 50, max 200
1426    let offset = params.offset.unwrap_or(0);
1427    let normalized_status = params
1428        .status
1429        .as_deref()
1430        .map(normalize_orders_status_filter_input);
1431    let status = normalized_status.as_deref();
1432
1433    let wallet = params.wallet;
1434    tracing::info!(
1435        "GET /orders request for wallet: {}, status: {:?}, limit: {}, offset: {}",
1436        wallet,
1437        status,
1438        limit,
1439        offset
1440    );
1441
1442    // Default (no status) and open/active statuses read from engine snapshot (engine truth).
1443    let is_open_status = matches!(
1444        status,
1445        None | Some("open") | Some("acked") | Some("partially_filled")
1446    );
1447
1448    let orders = if is_open_status {
1449        let summaries = app_state.order_snapshot.get_open_orders_for_wallet(&wallet);
1450        let mut api_orders: Vec<Order> = summaries
1451            .into_iter()
1452            .filter(|s| match status {
1453                Some("partially_filled") => s.remaining_size < s.original_size,
1454                Some("acked") | Some("open") => s.remaining_size == s.original_size,
1455                _ => true,
1456            })
1457            .map(|s| {
1458                let filled = s.original_size - s.remaining_size;
1459                let status_str = if s.remaining_size < s.original_size {
1460                    "partially_filled"
1461                } else {
1462                    "open"
1463                };
1464                Order {
1465                    order_id: i64::try_from(s.order_id).expect("Engine order_id exceeded i64::MAX"),
1466                    wallet_address: wallet,
1467                    symbol: s.symbol,
1468                    side: format!("{:?}", s.side),
1469                    price: s.price,
1470                    size: s.original_size,
1471                    tif: "gtc".to_string(),
1472                    status: Some(status_str.to_string()),
1473                    created_at: s.created_at,
1474                    updated_at: None,
1475                    filled_size: Some(filled),
1476                    mmp_enabled: s.mmp_enabled,
1477                }
1478            })
1479            .collect();
1480        // Apply pagination
1481        api_orders.sort_by_key(|o| std::cmp::Reverse(o.created_at));
1482        api_orders
1483            .into_iter()
1484            .skip(offset)
1485            .take(limit)
1486            .collect::<Vec<_>>()
1487    } else {
1488        let records = AnalyticsReader::get_orders_by_account(
1489            app_state.db.as_ref(),
1490            &wallet,
1491            status,
1492            limit,
1493            offset,
1494        )
1495        .await
1496        .map_err(|e| {
1497            error!("Failed to get orders from DB for {}: {}", wallet, e);
1498            StatusCode::INTERNAL_SERVER_ERROR
1499        })?;
1500        records.into_iter().map(Into::into).collect()
1501    };
1502    let count = orders.len();
1503
1504    Ok(SonicJson(OrdersResponse {
1505        success: true,
1506        data: orders,
1507        pagination: Pagination {
1508            limit,
1509            offset,
1510            count,
1511        },
1512    }))
1513}
1514
1515// =============================================================================
1516// Margin Mode Endpoint
1517// =============================================================================
1518
1519/// Set margin mode for a wallet (requires zero open positions)
1520#[utoipa::path(
1521    post,
1522    path = "/margin-mode",
1523    request_body = SetMarginModeRequest,
1524    responses(
1525        (status = 200, description = "Margin mode updated", body = hypercall_types::api_models::MarginModeApiResponse),
1526        (status = 400, description = "Invalid margin mode or has open positions"),
1527        (status = 503, description = "Margin mode service unavailable"),
1528        (status = 500, description = "Internal server error")
1529    ),
1530    security(("eip712_signature" = [])),
1531    tag = "Account"
1532)]
1533pub async fn set_margin_mode(
1534    State(state): State<AppState>,
1535    signer_ctx: SignerContext,
1536    SonicJson(request): SonicJson<crate::models::SetMarginModeRequest>,
1537) -> Result<SonicJson<crate::models::ApiResponse<crate::models::MarginModeResponse>>, ApiError> {
1538    // Ensure the caller is acting on their own wallet
1539    if request.wallet != signer_ctx.wallet_address {
1540        tracing::warn!(
1541            "Margin mode wallet mismatch: request_wallet={}, signer_wallet={}, signer={}",
1542            request.wallet,
1543            signer_ctx.wallet_address,
1544            signer_ctx.signer_address
1545        );
1546        return Err(ApiError::forbidden(
1547            "Wallet mismatch: signer does not match request wallet",
1548        ));
1549    }
1550
1551    let wallet = request.wallet;
1552
1553    tracing::info!(
1554        "Setting margin mode for wallet: {} to {} (signed by: {})",
1555        wallet,
1556        request.margin_mode,
1557        signer_ctx.signer_address
1558    );
1559
1560    // Validate margin mode value
1561    let new_mode = match request.margin_mode.to_lowercase().as_str() {
1562        "standard" => hypercall_types::MarginMode::Standard,
1563        "portfolio" if portfolio_margin_mode_allowed(&state.runtime_config, &wallet) => {
1564            hypercall_types::MarginMode::Portfolio
1565        }
1566        "portfolio" => {
1567            return Ok(SonicJson(ApiResponse {
1568                success: false,
1569                data: None,
1570                error: Some(
1571                    "Portfolio margin is not yet available on this environment.".to_string(),
1572                ),
1573            }));
1574        }
1575        _ => {
1576            tracing::warn!("Invalid margin mode: {}", request.margin_mode);
1577            return Ok(SonicJson(ApiResponse {
1578                success: false,
1579                data: None,
1580                error: Some(format!(
1581                    "Invalid margin mode '{}'. Must be 'standard' or 'portfolio'.",
1582                    request.margin_mode
1583                )),
1584            }));
1585        }
1586    };
1587
1588    // Get the current margin mode if a row already exists. A missing row is
1589    // allowed here because this endpoint creates the explicit requested mode.
1590    let current_mode = state
1591        .tier_cache
1592        .get_existing_margin_mode(&wallet)
1593        .await
1594        .map_err(|error| {
1595            ApiError::new(
1596                StatusCode::SERVICE_UNAVAILABLE,
1597                "service_unavailable",
1598                format!("Margin mode unavailable for {}: {}", wallet, error),
1599            )
1600        })?;
1601
1602    // Check if already in requested mode
1603    if current_mode == Some(new_mode) {
1604        return Ok(SonicJson(ApiResponse {
1605            success: false,
1606            data: None,
1607            error: Some(format!(
1608                "Account is already in {} margin mode.",
1609                new_mode.as_str()
1610            )),
1611        }));
1612    }
1613
1614    // Check for open positions from canonical in-memory state (no repricing path).
1615    let position_count = state.portfolio_cache.open_position_count(&wallet).await;
1616    let has_positions = position_count > 0;
1617
1618    if has_positions {
1619        return Ok(SonicJson(ApiResponse {
1620            success: false,
1621            data: None,
1622            error: Some(format!(
1623                "Cannot change margin mode with {} open position(s). Close all positions first.",
1624                position_count
1625            )),
1626        }));
1627    }
1628
1629    // Check for open orders using engine-truth snapshot
1630    let open_orders = state.order_snapshot.get_open_orders_for_wallet(&wallet);
1631    if !open_orders.is_empty() {
1632        return Ok(SonicJson(ApiResponse {
1633            success: false,
1634            data: None,
1635            error: Some(format!(
1636                "Cannot change margin mode with {} open order(s). Cancel all orders first.",
1637                open_orders.len()
1638            )),
1639        }));
1640    }
1641
1642    // Set the new margin mode (returns the new version for event ordering)
1643    let new_version = match state.tier_cache.set_margin_mode(&wallet, new_mode).await {
1644        Ok(version) => version,
1645        Err(e) => {
1646            tracing::error!("Failed to set margin mode for {}: {}", wallet, e);
1647            return Err(ApiError::internal_error("Failed to set margin mode"));
1648        }
1649    };
1650
1651    if let Err(e) = super::submit_tier_update_command(&state, wallet).await {
1652        tracing::error!("Failed to apply margin mode update in engine: {}", e);
1653        if let Some(previous_mode) = current_mode {
1654            if let Err(rollback_err) = state
1655                .tier_cache
1656                .set_margin_mode(&wallet, previous_mode)
1657                .await
1658            {
1659                tracing::error!(
1660                    "Failed to roll back margin mode for {} after engine apply failure: {}",
1661                    wallet,
1662                    rollback_err
1663                );
1664            }
1665        } else {
1666            if let Err(delete_err) = state.tier_cache.delete_tier(&wallet).await {
1667                tracing::error!(
1668                    "Failed to delete newly created margin mode row for {} after engine apply failure: {}",
1669                    wallet,
1670                    delete_err
1671                );
1672            } else {
1673                tracing::warn!(
1674                    "Deleted newly created margin mode row for {} after engine apply failure",
1675                    wallet
1676                );
1677            }
1678        }
1679        return Err(ApiError::internal_error(
1680            "Failed to apply margin mode update",
1681        ));
1682    }
1683
1684    // Publish TierUpdate message for cross-process cache synchronization
1685    let tier_update =
1686        hypercall_types::EngineMessage::TierUpdate(hypercall_types::TierUpdateMessage {
1687            wallet,
1688            margin_mode: new_mode.as_str().to_string(),
1689            version: new_version,
1690            timestamp: chrono::Utc::now().timestamp() as u64,
1691        });
1692    if let Err(e) = state.event_bus_sender.send(tier_update) {
1693        // Log but don't fail - local cache is already updated
1694        tracing::warn!("Failed to publish TierUpdate for cross-process sync: {}", e);
1695    }
1696
1697    let previous_mode = current_mode.map(|mode| mode.as_str()).unwrap_or("unset");
1698
1699    tracing::info!(
1700        "Margin mode changed for wallet {}: {} -> {}",
1701        wallet,
1702        previous_mode,
1703        new_mode.as_str()
1704    );
1705
1706    Ok(SonicJson(ApiResponse::success(
1707        crate::models::MarginModeResponse {
1708            wallet: wallet.to_string(),
1709            margin_mode: new_mode.as_str().to_string(),
1710            previous_mode: previous_mode.to_string(),
1711        },
1712    )))
1713}