1#[cfg(feature = "faucet")]
2use alloy::primitives::FixedBytes;
3#[cfg(feature = "faucet")]
4use rust_decimal::prelude::ToPrimitive;
5use utoipa::ToSchema;
6
7use crate::error::ApiError;
8use crate::sonic_json::SonicJson;
9use axum::extract::State;
10#[cfg(any(feature = "test-endpoints", all(test, feature = "faucet")))]
11use axum::http::StatusCode;
12#[cfg(feature = "test-endpoints")]
13use hypercall_runtime_api::MarketRequest;
14#[cfg(feature = "test-endpoints")]
15use hypercall_types::utils::get_timestamp_millis;
16#[cfg(feature = "test-endpoints")]
17use hypercall_types::ParsedOptionSymbol;
18#[cfg(any(feature = "faucet", feature = "test-endpoints"))]
19use hypercall_types::WalletAddress;
20use serde::{Deserialize, Serialize};
21
22use super::AppState;
23
24#[cfg(feature = "faucet")]
25use super::{FAUCET_LIFETIME_CAP_USDC, MARKET_MAKER_TIER};
26#[cfg(feature = "faucet")]
27use hypercall_db::FaucetWriter;
28#[cfg(feature = "faucet")]
29use hypercall_runtime_api::DepositRequest;
30use rust_decimal::Decimal;
31
32#[cfg(feature = "faucet")]
33fn synthetic_faucet_source_event_hash(sequence: u64) -> FixedBytes<32> {
34 let mut bytes = [0u8; 32];
35 bytes[..8].copy_from_slice(&sequence.to_be_bytes());
36 FixedBytes::from(bytes)
37}
38
39#[cfg(feature = "test-endpoints")]
44#[derive(Debug, Deserialize, ToSchema)]
45pub struct TestBalanceLedgerRequest {
46 #[schema(value_type = String)]
47 pub wallet: hypercall_types::WalletAddress,
48}
49
50#[cfg(feature = "test-endpoints")]
51#[derive(Debug, Serialize, ToSchema)]
52pub struct TestBalanceLedgerResponse {
53 #[schema(value_type = String)]
54 pub wallet: hypercall_types::WalletAddress,
55 pub balance: String,
56}
57
58#[cfg(feature = "test-endpoints")]
59pub async fn get_balance_ledger(
60 State(state): State<AppState>,
61 SonicJson(request): SonicJson<TestBalanceLedgerRequest>,
62) -> Result<SonicJson<TestBalanceLedgerResponse>, ApiError> {
63 if !state.runtime_config.testnet_mode {
64 return Err(ApiError::forbidden(
65 "Balance ledger test endpoint requires testnet mode",
66 ));
67 }
68
69 let balance = state
70 .balance_provider
71 .get_balance(&request.wallet)
72 .await
73 .map_err(|error| {
74 ApiError::internal_error(format!("failed to read engine balance ledger: {error}"))
75 })?;
76 Ok(SonicJson(TestBalanceLedgerResponse {
77 wallet: request.wallet,
78 balance: balance.to_string(),
79 }))
80}
81
82#[cfg(feature = "test-endpoints")]
87#[derive(Debug, Deserialize, ToSchema)]
88pub struct TestOptionDepositRequest {
89 #[schema(value_type = String)]
90 pub wallet: hypercall_types::WalletAddress,
91 pub symbol: String,
92 pub quantity: String,
93}
94
95#[cfg(feature = "test-endpoints")]
96#[derive(Debug, Serialize, ToSchema)]
97pub struct TestOptionDepositResponse {
98 pub request_id: String,
99 #[schema(value_type = String)]
100 pub wallet: hypercall_types::WalletAddress,
101 pub symbol: String,
102 pub quantity: String,
103}
104
105#[cfg(feature = "test-endpoints")]
106pub async fn apply_option_deposit(
107 State(state): State<AppState>,
108 SonicJson(request): SonicJson<TestOptionDepositRequest>,
109) -> Result<SonicJson<TestOptionDepositResponse>, ApiError> {
110 if !state.runtime_config.testnet_mode {
111 return Err(ApiError::forbidden(
112 "Option deposit test endpoint requires testnet mode",
113 ));
114 }
115
116 let quantity = request
117 .quantity
118 .parse::<Decimal>()
119 .map_err(|_| ApiError::bad_request("quantity must be a decimal string"))?;
120 if quantity <= Decimal::ZERO {
121 return Err(ApiError::bad_request("quantity must be positive"));
122 }
123
124 let sender = state.option_deposit_sender.clone().ok_or_else(|| {
125 ApiError::new(
126 StatusCode::SERVICE_UNAVAILABLE,
127 "service_unavailable",
128 "option deposit engine path disabled",
129 )
130 })?;
131 let request_id = uuid::Uuid::now_v7().to_string();
132 let timestamp_ms = get_timestamp_millis();
133 let (applied_tx, applied_rx) = tokio::sync::oneshot::channel();
134 sender
135 .send(hypercall_runtime_api::OptionDepositRequest {
136 request_id: request_id.clone(),
137 wallet: request.wallet,
138 symbol: request.symbol.clone(),
139 quantity,
140 timestamp_ms,
141 applied_tx: Some(applied_tx),
142 })
143 .await
144 .map_err(|_| {
145 ApiError::new(
146 StatusCode::SERVICE_UNAVAILABLE,
147 "service_unavailable",
148 "option deposit engine path closed",
149 )
150 })?;
151 applied_rx
152 .await
153 .map_err(|_| {
154 ApiError::new(
155 StatusCode::SERVICE_UNAVAILABLE,
156 "service_unavailable",
157 "option deposit apply dropped",
158 )
159 })?
160 .map_err(ApiError::bad_request)?;
161
162 Ok(SonicJson(TestOptionDepositResponse {
163 request_id,
164 wallet: request.wallet,
165 symbol: request.symbol,
166 quantity: quantity.to_string(),
167 }))
168}
169
170#[cfg(feature = "faucet")]
175#[derive(Debug, Deserialize, ToSchema)]
177pub struct FaucetRequest {
178 #[schema(value_type = String)]
180 pub wallet: WalletAddress,
181 #[serde(default = "default_faucet_amount")]
183 pub amount: f64,
184}
185
186#[cfg(feature = "faucet")]
187fn default_faucet_amount() -> f64 {
188 10000.0
189}
190
191#[cfg(feature = "faucet")]
192fn faucet_cap_exceeded_message() -> String {
193 format!(
194 "faucet lifetime deposit cap exceeded: wallet cap is {} USDC",
195 FAUCET_LIFETIME_CAP_USDC
196 )
197}
198
199#[cfg(feature = "faucet")]
200#[derive(Clone, Copy, Debug, Eq, PartialEq)]
201enum FaucetCapMode {
202 Enforced,
203 MarketMakerExempt,
204}
205
206#[cfg(feature = "faucet")]
207impl FaucetCapMode {
208 fn enforces_limit(self) -> bool {
209 matches!(self, Self::Enforced)
210 }
211}
212
213#[cfg(feature = "faucet")]
214async fn resolve_faucet_cap_mode(state: &AppState, wallet: &WalletAddress) -> FaucetCapMode {
215 match state.tier_cache.get_tier(wallet).await {
216 Some(tier) if tier.tier == MARKET_MAKER_TIER => FaucetCapMode::MarketMakerExempt,
217 _ => FaucetCapMode::Enforced,
218 }
219}
220
221#[cfg(feature = "faucet")]
222async fn do_persist_faucet_credit(
223 db: &dyn FaucetWriter,
224 wallet: &WalletAddress,
225 amount: Decimal,
226 request_ts_ms: i64,
227 cap_mode: FaucetCapMode,
228) -> Result<u64, ApiError> {
229 let limit = match cap_mode {
231 FaucetCapMode::Enforced => Decimal::from(FAUCET_LIFETIME_CAP_USDC),
232 FaucetCapMode::MarketMakerExempt => Decimal::MAX,
233 };
234
235 let result = db
236 .persist_faucet_credit(wallet, amount, limit, request_ts_ms)
237 .await
238 .map_err(|e| {
239 let msg = e.to_string();
240 if msg.contains("exceed") {
241 ApiError::bad_request(faucet_cap_exceeded_message())
242 } else {
243 tracing::error!("Faucet: failed to persist credit for {}: {}", wallet, e);
244 ApiError::internal_error("Failed to persist faucet credit")
245 }
246 })?;
247
248 Ok(result.ledger_event_id as u64)
249}
250
251#[cfg(feature = "faucet")]
252fn engine_deposit_unavailable_error() -> ApiError {
253 ApiError::new(
254 axum::http::StatusCode::SERVICE_UNAVAILABLE,
255 "service_unavailable",
256 "Faucet engine deposit path is unavailable",
257 )
258}
259
260#[cfg(feature = "faucet")]
261async fn reserve_deposit_handoff(
262 deposit_sender: &Option<tokio::sync::mpsc::Sender<DepositRequest>>,
263) -> Result<tokio::sync::mpsc::OwnedPermit<DepositRequest>, ApiError> {
264 let Some(deposit_tx) = deposit_sender else {
265 return Err(engine_deposit_unavailable_error());
266 };
267
268 deposit_tx.clone().reserve_owned().await.map_err(|e| {
269 tracing::error!("Faucet: engine deposit handoff unavailable: {}", e);
270 engine_deposit_unavailable_error()
271 })
272}
273
274#[cfg(feature = "faucet")]
275#[derive(Debug, Serialize, ToSchema)]
277pub struct FaucetResponse {
278 #[schema(value_type = String)]
279 pub wallet: WalletAddress,
280 pub amount_credited: f64,
281 pub new_balance: f64,
282}
283
284#[cfg(feature = "faucet")]
285#[utoipa::path(
290 post,
291 path = "/faucet",
292 request_body = FaucetRequest,
293 responses(
294 (status = 200, description = "Funds credited successfully", body = ApiResponse<FaucetResponse>),
295 (status = 400, description = "Invalid request"),
296 (status = 403, description = "Faucet not enabled (production mode)"),
297 (status = 500, description = "Internal server error")
298 ),
299 tag = "testnet"
300)]
301pub async fn faucet(
302 State(state): State<AppState>,
303 SonicJson(request): SonicJson<FaucetRequest>,
304) -> Result<SonicJson<crate::models::ApiResponse<FaucetResponse>>, ApiError> {
305 if !state.runtime_config.testnet_mode {
307 tracing::warn!("Faucet request rejected, testnet mode is disabled in backend config.");
308 return Err(ApiError::forbidden(
309 "Faucet not enabled, set modes.testnet_mode=true",
310 ));
311 }
312
313 if !request.amount.is_finite() || request.amount <= 0.0 {
316 tracing::error!("Faucet request with invalid amount: {}", request.amount);
317 return Err(ApiError::bad_request(
318 "Amount must be a finite positive number",
319 ));
320 }
321
322 let amount = request.amount;
323 let amount_decimal = Decimal::from_f64_retain(amount)
324 .ok_or_else(|| ApiError::bad_request("Amount must be a finite positive number"))?;
325 let cap_mode = resolve_faucet_cap_mode(&state, &request.wallet).await;
326 if cap_mode.enforces_limit() && amount_decimal > Decimal::from(FAUCET_LIFETIME_CAP_USDC) {
327 return Err(ApiError::bad_request(faucet_cap_exceeded_message()));
328 }
329
330 tracing::info!(
331 "Faucet: Crediting {} USDC to wallet {}",
332 amount,
333 request.wallet
334 );
335
336 let deposit_permit = reserve_deposit_handoff(&state.deposit_sender).await?;
337 let now_ts_ms = chrono::Utc::now().timestamp_millis();
338 let sequence = do_persist_faucet_credit(
339 state.db.as_ref(),
340 &request.wallet,
341 amount_decimal,
342 now_ts_ms,
343 cap_mode,
344 )
345 .await?;
346
347 let (applied_tx, applied_rx) = tokio::sync::oneshot::channel();
348 deposit_permit.send(DepositRequest {
349 wallet: request.wallet,
350 amount: amount_decimal,
351 timestamp_ms: now_ts_ms as u64,
352 sequence: Some(sequence),
353 source_event_hash: synthetic_faucet_source_event_hash(sequence),
354 journal_request_id: uuid::Uuid::now_v7().to_string(),
360 outbox_appends: Vec::new(),
361 applied_tx: Some(applied_tx),
362 });
363
364 match applied_rx.await {
365 Ok(Ok(())) => {}
366 Ok(Err(e)) => panic!(
367 "Faucet: DB commit succeeded for {} but engine deposit apply failed: {}",
368 request.wallet, e
369 ),
370 Err(e) => panic!(
371 "Faucet: DB commit succeeded for {} but engine deposit apply ack dropped: {}",
372 request.wallet, e
373 ),
374 }
375 let new_balance = state
376 .balance_provider
377 .get_balance(&request.wallet)
378 .await
379 .map_err(|error| {
380 ApiError::internal_error(format!("failed to read engine balance ledger: {error}"))
381 })?;
382
383 tracing::info!(
384 "Faucet: Credited {} USDC to wallet {} via engine balance ledger (new balance: {})",
385 amount,
386 request.wallet,
387 new_balance
388 );
389
390 Ok(SonicJson(crate::models::ApiResponse::success(
391 FaucetResponse {
392 wallet: request.wallet,
393 amount_credited: amount,
394 new_balance: new_balance
395 .to_f64()
396 .expect("Faucet: new_balance Decimal not representable as f64"),
397 },
398 )))
399}
400
401#[cfg(feature = "test-endpoints")]
406#[derive(Debug, Deserialize, ToSchema)]
408pub struct SetSpotPriceRequest {
409 pub underlying: String,
411 pub price: f64,
413}
414
415#[cfg(feature = "test-endpoints")]
416#[derive(Debug, Serialize, ToSchema)]
418pub struct SetSpotPriceResponse {
419 pub underlying: String,
421 pub price: f64,
423}
424
425#[cfg(feature = "test-endpoints")]
426#[derive(Debug, Deserialize, ToSchema)]
428pub struct SetOptionIvRequest {
429 pub symbol: String,
431 pub implied_vol: f64,
433}
434
435#[cfg(feature = "test-endpoints")]
436#[derive(Debug, Serialize, ToSchema)]
438pub struct SetOptionIvResponse {
439 pub symbol: String,
441 pub implied_vol: f64,
443}
444
445#[cfg(feature = "test-endpoints")]
446#[utoipa::path(
451 post,
452 path = "/test/spot-price",
453 request_body = SetSpotPriceRequest,
454 responses(
455 (status = 200, description = "Spot price set successfully", body = ApiResponse<SetSpotPriceResponse>),
456 (status = 400, description = "Invalid request"),
457 (status = 403, description = "Not enabled (production mode)"),
458 (status = 404, description = "Oracle not configured for underlying"),
459 (status = 500, description = "Internal server error")
460 ),
461 tag = "testnet"
462)]
463pub async fn set_spot_price(
464 State(state): State<AppState>,
465 SonicJson(request): SonicJson<SetSpotPriceRequest>,
466) -> Result<SonicJson<crate::models::ApiResponse<SetSpotPriceResponse>>, ApiError> {
467 if !request.price.is_finite() || request.price <= 0.0 {
468 tracing::error!(
469 "set_spot_price request with invalid price: {}",
470 request.price
471 );
472 return Err(ApiError::bad_request(
473 "Price must be a finite positive number",
474 ));
475 }
476
477 tracing::info!(
478 "Setting spot price for {} to ${:.2}",
479 request.underlying,
480 request.price
481 );
482
483 let success = state
485 .greeks_cache
486 .set_spot_price_for_testing(&request.underlying, request.price)
487 .await;
488
489 if !success {
490 tracing::error!(
491 "Failed to set spot price for {}: oracle not configured",
492 request.underlying
493 );
494 return Err(ApiError::not_found(format!(
495 "Oracle not configured for underlying: {}",
496 request.underlying
497 )));
498 }
499
500 Ok(SonicJson(crate::models::ApiResponse::success(
501 SetSpotPriceResponse {
502 underlying: request.underlying,
503 price: request.price,
504 },
505 )))
506}
507
508#[cfg(feature = "test-endpoints")]
509#[utoipa::path(
513 post,
514 path = "/test/option-iv",
515 request_body = SetOptionIvRequest,
516 responses(
517 (status = 200, description = "Option IV set successfully", body = ApiResponse<SetOptionIvResponse>),
518 (status = 400, description = "Invalid request"),
519 (status = 403, description = "Not enabled (production mode)")
520 ),
521 tag = "testnet"
522)]
523pub async fn set_option_iv(
524 State(state): State<AppState>,
525 SonicJson(request): SonicJson<SetOptionIvRequest>,
526) -> Result<SonicJson<crate::models::ApiResponse<SetOptionIvResponse>>, ApiError> {
527 if request.implied_vol <= 0.0 || !request.implied_vol.is_finite() {
528 return Err(ApiError::bad_request(
529 "implied_vol must be a finite positive decimal value",
530 ));
531 }
532
533 state
534 .greeks_cache
535 .set_theoretical_iv_for_testing(&request.symbol, request.implied_vol)
536 .await;
537
538 Ok(SonicJson(crate::models::ApiResponse::success(
539 SetOptionIvResponse {
540 symbol: request.symbol,
541 implied_vol: request.implied_vol,
542 },
543 )))
544}
545
546#[cfg(feature = "test-endpoints")]
551#[derive(Debug, Deserialize, ToSchema)]
553pub struct ExpireInstrumentRequest {
554 pub symbol: String,
556}
557
558#[cfg(feature = "test-endpoints")]
559#[derive(Debug, Serialize, ToSchema)]
561pub struct ExpireInstrumentResponse {
562 pub symbol: String,
564 pub status: String,
566 #[serde(skip_serializing_if = "Option::is_none")]
568 pub reason: Option<String>,
569}
570
571#[cfg(feature = "test-endpoints")]
572#[utoipa::path(
578 post,
579 path = "/test/expire-instrument",
580 request_body = ExpireInstrumentRequest,
581 responses(
582 (status = 200, description = "Instrument expired/pending settlement", body = ApiResponse<ExpireInstrumentResponse>),
583 (status = 400, description = "Invalid symbol"),
584 (status = 403, description = "Not enabled (production mode)"),
585 (status = 500, description = "Engine communication failure")
586 ),
587 tag = "testnet"
588)]
589pub async fn expire_instrument(
590 State(state): State<AppState>,
591 SonicJson(request): SonicJson<ExpireInstrumentRequest>,
592) -> Result<SonicJson<crate::models::ApiResponse<ExpireInstrumentResponse>>, ApiError> {
593 let parsed = ParsedOptionSymbol::from_symbol(&request.symbol)
595 .map_err(|e| ApiError::bad_request(format!("Invalid symbol: {}", e)))?;
596
597 tracing::info!("Expiring instrument {} (testnet)", request.symbol);
598
599 let market = hypercall_types::Market {
600 symbol: request.symbol.clone(),
601 underlying: parsed.underlying,
602 expiry: parsed.expiry,
603 strike: parsed.strike,
604 option_type: parsed.option_type,
605 };
606
607 let message = hypercall_types::MarketActionMessage {
608 market,
609 action: hypercall_types::MarketAction::ExpireMarket,
610 timestamp: get_timestamp_millis(),
611 };
612
613 let (response_tx, mut response_rx) = tokio::sync::mpsc::channel(1);
614
615 let market_request = MarketRequest {
616 message,
617 response_tx,
618 };
619
620 state
621 .market_sender
622 .send(market_request)
623 .await
624 .map_err(|_| ApiError::internal_error("Failed to send expire request to engine"))?;
625
626 let response = tokio::time::timeout(std::time::Duration::from_secs(30), response_rx.recv())
628 .await
629 .map_err(|_| ApiError::internal_error("Timeout waiting for engine response"))?
630 .ok_or_else(|| ApiError::internal_error("Engine dropped response channel"))?;
631
632 let (status_str, reason) = match response.status {
633 hypercall_types::MarketUpdateStatus::MarketExpired => ("SETTLED".to_string(), None),
634 hypercall_types::MarketUpdateStatus::MarketPendingSettlement => {
635 ("PENDING_SETTLEMENT".to_string(), response.reason)
636 }
637 other => ("UNEXPECTED".to_string(), Some(format!("{:?}", other))),
638 };
639
640 Ok(SonicJson(crate::models::ApiResponse::success(
641 ExpireInstrumentResponse {
642 symbol: request.symbol,
643 status: status_str,
644 reason,
645 },
646 )))
647}
648
649#[derive(Debug, Serialize, ToSchema)]
655pub struct CancelAllOrdersResponse {
656 pub total_mm_orders: usize,
658 pub message: String,
660}
661
662#[cfg(test)]
663mod faucet_tests {
664 use super::*;
665
666 #[cfg(feature = "faucet")]
667 #[tokio::test]
668 async fn test_reserve_deposit_handoff_rejects_missing_sender() {
669 let err = reserve_deposit_handoff(&None)
670 .await
671 .expect_err("missing engine deposit sender should fail before DB persistence");
672 assert_eq!(err.status, StatusCode::SERVICE_UNAVAILABLE);
673 }
674
675 #[cfg(feature = "faucet")]
676 #[tokio::test]
677 async fn test_reserve_deposit_handoff_rejects_closed_sender() {
678 let (deposit_tx, deposit_rx) = tokio::sync::mpsc::channel(1);
679 drop(deposit_rx);
680
681 let err = reserve_deposit_handoff(&Some(deposit_tx))
682 .await
683 .expect_err("closed engine deposit sender should fail before DB persistence");
684 assert_eq!(err.status, StatusCode::SERVICE_UNAVAILABLE);
685 }
686
687 #[cfg(feature = "faucet")]
688 #[tokio::test]
689 async fn test_reserve_deposit_handoff_accepts_open_sender() {
690 let (deposit_tx, _deposit_rx) = tokio::sync::mpsc::channel(1);
691
692 reserve_deposit_handoff(&Some(deposit_tx))
693 .await
694 .expect("open engine deposit sender should reserve handoff capacity");
695 }
696}