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#[derive(Debug, Deserialize, IntoParams)]
39pub struct TradesQuery {
40 #[param(maximum = 1000)]
42 limit: Option<usize>,
43 offset: Option<usize>,
45 symbol: Option<String>,
47 underlying: Option<String>,
49 account: Option<WalletAddress>,
51}
52
53#[derive(Debug, Deserialize, IntoParams)]
54pub struct PortfolioQuery {
55 #[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#[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 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#[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 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 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 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 #[param(example = "0x1234567890abcdef1234567890abcdef12345678")]
1179 pub wallet: WalletAddress,
1180}
1181
1182#[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 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 #[param(example = "0x1234567890abcdef1234567890abcdef12345678")]
1283 pub wallet: WalletAddress,
1284 #[param(maximum = 1000)]
1286 pub limit: Option<usize>,
1287 pub offset: Option<usize>,
1289}
1290
1291#[derive(Debug, Deserialize, IntoParams)]
1292pub struct OrdersQuery {
1293 #[param(example = "0x1234567890abcdef1234567890abcdef12345678")]
1295 pub wallet: WalletAddress,
1296 pub status: Option<String>,
1298 #[param(maximum = 50)]
1300 pub limit: Option<usize>,
1301 pub offset: Option<usize>,
1303}
1304
1305#[derive(Debug, Deserialize, IntoParams)]
1306pub struct MarketsQuery {
1307 #[param(example = "false")]
1309 include_instruments: Option<bool>,
1310}
1311
1312#[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 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#[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 ¶ms.wallet,
1383 limit,
1384 offset,
1385 )
1386 .await
1387 {
1388 Ok(records) => {
1389 let count = records.len();
1390 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#[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); 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 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 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#[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 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 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 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 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 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 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 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 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 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}