1use alloy::primitives::{Address, B256};
2use anyhow::{bail, Context, Result};
3use catalog_manager::{CatalogConfig, SecretPlaceholderMode};
4pub use hypercall_config::{
5 ApiRuntimeConfig, PricingConfig, DEFAULT_HYPERLIQUID_INFO_URL,
6 DEFAULT_HYPERLIQUID_TESTNET_INFO_URL,
7};
8use hypercall_transaction_submitter::{TransactionSubmitterConfig, TransactionSubmitterMode};
9use serde::Deserialize;
10use std::path::{Path, PathBuf};
11use std::str::FromStr;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum SecretValidationMode {
15 FullRuntime,
16 Migrations,
17 ConfigOnly,
18 DatabaseOnly,
19}
20
21#[derive(Debug, Clone)]
22pub struct BackendRuntime {
23 pub config: BackendConfig,
24 pub secrets: BackendSecrets,
25}
26
27#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum RsmEnvironmentConfig {
30 #[default]
31 Development,
32 Staging,
33 Testnet,
34 Mainnet,
35}
36
37#[cfg(feature = "rsm-state")]
38impl From<RsmEnvironmentConfig> for hypercall_db::ValidatorRsmEnvironment {
39 fn from(value: RsmEnvironmentConfig) -> Self {
40 match value {
41 RsmEnvironmentConfig::Development => Self::Development,
42 RsmEnvironmentConfig::Staging => Self::Staging,
43 RsmEnvironmentConfig::Testnet => Self::Testnet,
44 RsmEnvironmentConfig::Mainnet => Self::Mainnet,
45 }
46 }
47}
48
49impl BackendRuntime {
50 pub fn load_from_path(path: &Path) -> Result<Self> {
51 Self::load_from_path_with_secret_validation(path, SecretValidationMode::FullRuntime)
52 }
53
54 pub fn load_from_path_for_migrations(path: &Path) -> Result<Self> {
55 Self::load_from_path_with_secret_validation(path, SecretValidationMode::Migrations)
56 }
57
58 pub fn load_from_path_for_config_only(path: &Path) -> Result<Self> {
59 Self::load_from_path_with_secret_validation(path, SecretValidationMode::ConfigOnly)
60 }
61
62 pub fn load_from_path_for_database_only(path: &Path) -> Result<Self> {
63 Self::load_from_path_with_secret_validation(path, SecretValidationMode::DatabaseOnly)
64 }
65
66 fn load_from_path_with_secret_validation(
67 path: &Path,
68 secret_validation: SecretValidationMode,
69 ) -> Result<Self> {
70 let contents = std::fs::read_to_string(path)
71 .with_context(|| format!("Failed to read backend config {}", path.display()))?;
72 Self::load_from_str_with_secret_validation(&contents, secret_validation)
73 .with_context(|| format!("Failed to parse backend config {}", path.display()))
74 }
75
76 pub fn load_from_str(contents: &str) -> Result<Self> {
77 Self::load_from_str_with_secret_validation(contents, SecretValidationMode::FullRuntime)
78 }
79
80 #[cfg(test)]
81 fn load_from_str_for_migrations(contents: &str) -> Result<Self> {
82 Self::load_from_str_with_secret_validation(contents, SecretValidationMode::Migrations)
83 }
84
85 #[cfg(test)]
86 fn load_from_str_for_config_only(contents: &str) -> Result<Self> {
87 Self::load_from_str_with_secret_validation(contents, SecretValidationMode::ConfigOnly)
88 }
89
90 #[cfg(test)]
91 fn load_from_str_for_database_only(contents: &str) -> Result<Self> {
92 Self::load_from_str_with_secret_validation(contents, SecretValidationMode::DatabaseOnly)
93 }
94
95 fn load_from_str_with_secret_validation(
96 contents: &str,
97 secret_validation: SecretValidationMode,
98 ) -> Result<Self> {
99 let config: BackendConfig = catalog_manager::with_secret_placeholder_mode(
100 secret_placeholder_mode(secret_validation),
101 || serde_yaml_ng::from_str(contents).context("Invalid backend YAML config"),
102 )?;
103 config.validate_for_mode(secret_validation)?;
104
105 let secrets = BackendSecrets::from_env();
106 secrets.validate_for_mode(&config, secret_validation)?;
107
108 Ok(Self { config, secrets })
109 }
110
111 pub fn from_legacy_env() -> Result<Self> {
112 Self::from_legacy_env_with_secret_validation(SecretValidationMode::FullRuntime)
113 }
114
115 pub fn from_legacy_env_for_migrations() -> Result<Self> {
116 Self::from_legacy_env_with_secret_validation(SecretValidationMode::Migrations)
117 }
118
119 pub fn from_legacy_env_for_config_only() -> Result<Self> {
120 Self::from_legacy_env_with_secret_validation(SecretValidationMode::ConfigOnly)
121 }
122
123 pub fn from_legacy_env_for_database_only() -> Result<Self> {
124 Self::from_legacy_env_with_secret_validation(SecretValidationMode::DatabaseOnly)
125 }
126
127 fn from_legacy_env_with_secret_validation(
128 secret_validation: SecretValidationMode,
129 ) -> Result<Self> {
130 let mut config = BackendConfig::default();
131 apply_legacy_env_overrides(&mut config, should_load_legacy_catalog(secret_validation))?;
132 config.validate_for_mode(secret_validation)?;
133
134 let secrets = BackendSecrets::from_env();
135 secrets.validate_for_mode(&config, secret_validation)?;
136
137 Ok(Self { config, secrets })
138 }
139
140 pub fn apply_process_env(&self) {
141 std::env::set_var(
142 "TESTNET_MODE",
143 if self.config.modes.testnet_mode {
144 "1"
145 } else {
146 "0"
147 },
148 );
149 std::env::set_var(
150 "EXCHANGE_CONTRACT_ADDRESS",
151 &self.config.contracts.exchange_contract_address,
152 );
153 std::env::set_var(
154 "OPTION_REGISTRY_ADDRESS",
155 &self.config.contracts.option_registry_address,
156 );
157 std::env::set_var(
158 "OPTION_TOKEN_BEACON_PROXY_INIT_CODE_HASH",
159 &self
160 .config
161 .contracts
162 .option_token_beacon_proxy_init_code_hash,
163 );
164 }
165
166 pub fn apply_portfolio_margin_pool_env_overrides(&mut self) -> Result<()> {
167 apply_portfolio_margin_pool_env_overrides(&mut self.config)
168 }
169}
170
171fn secret_placeholder_mode(secret_validation: SecretValidationMode) -> SecretPlaceholderMode {
172 match secret_validation {
173 SecretValidationMode::FullRuntime => SecretPlaceholderMode::ResolveRequired,
174 SecretValidationMode::Migrations
175 | SecretValidationMode::ConfigOnly
176 | SecretValidationMode::DatabaseOnly => SecretPlaceholderMode::AllowUnresolved,
177 }
178}
179
180fn should_load_legacy_catalog(secret_validation: SecretValidationMode) -> bool {
181 matches!(secret_validation, SecretValidationMode::FullRuntime)
182}
183
184#[derive(Debug, Clone, Deserialize)]
185#[serde(default, deny_unknown_fields)]
186pub struct BackendConfig {
187 pub environment: EnvironmentConfig,
188 pub modes: ModesConfig,
189 pub contracts: ContractsConfig,
190 pub database: DatabaseConfig,
191 pub api: ApiRuntimeConfig,
192 pub pricing: PricingConfig,
193 pub hydromancer: HydromancerRuntimeConfig,
194 pub transaction_submitter: TransactionSubmitterConfig,
195 pub rsm_signer: RsmSignerConfig,
196 pub liquidation: LiquidationRuntimeConfig,
197 pub onchain_deposits: OnchainDepositsRuntimeConfig,
198 pub catalog_manager: CatalogManagerRuntimeConfig,
199 pub catalog: CatalogConfig,
200 pub engine: EngineRuntimeConfig,
201 pub background_tasks: BackgroundTasksConfig,
202 pub observability: ObservabilityRuntimeConfig,
203}
204
205impl Default for BackendConfig {
206 fn default() -> Self {
207 Self {
208 environment: EnvironmentConfig::default(),
209 modes: ModesConfig::default(),
210 contracts: ContractsConfig::default(),
211 database: DatabaseConfig::default(),
212 api: ApiRuntimeConfig::default(),
213 pricing: PricingConfig::default(),
214 hydromancer: HydromancerRuntimeConfig::default(),
215 transaction_submitter: TransactionSubmitterConfig::default(),
216 rsm_signer: RsmSignerConfig::default(),
217 liquidation: LiquidationRuntimeConfig::default(),
218 onchain_deposits: OnchainDepositsRuntimeConfig::default(),
219 catalog_manager: CatalogManagerRuntimeConfig::default(),
220 catalog: default_catalog(),
221 engine: EngineRuntimeConfig::default(),
222 background_tasks: BackgroundTasksConfig::default(),
223 observability: ObservabilityRuntimeConfig::default(),
224 }
225 }
226}
227
228impl BackendConfig {
229 pub fn validate(&self) -> Result<()> {
230 self.validate_for_mode(SecretValidationMode::FullRuntime)
231 }
232
233 fn validate_for_mode(&self, secret_validation: SecretValidationMode) -> Result<()> {
234 if mode_requires_contract_validation(secret_validation) {
235 self.contracts.validate()?;
236 }
237 if self.database.pool.diesel_max < 3 {
238 bail!("database.pool.diesel_max must be >= 3 (sync needs >=1, async needs >=2)");
239 }
240 if self.database.pool.journal_max == 0 {
241 bail!("database.pool.journal_max must be > 0");
242 }
243 if self.api.max_open_orders_default < 0 {
244 bail!("api.max_open_orders_default must be >= 0");
245 }
246 if self.api.max_open_positions_default < 0 {
247 bail!("api.max_open_positions_default must be >= 0");
248 }
249 if self.api.ws_heartbeat_interval_ms == 0 {
250 bail!("api.ws_heartbeat_interval_ms must be > 0");
251 }
252 if self.api.ws_pong_timeout_ms < self.api.ws_heartbeat_interval_ms {
253 bail!("api.ws_pong_timeout_ms must be >= api.ws_heartbeat_interval_ms");
254 }
255 if self.api.markets_snapshot_refresh_ms == 0 {
256 bail!("api.markets_snapshot_refresh_ms must be > 0");
257 }
258 if self.pricing.candle_ws_poll_interval_ms == 0 {
259 bail!("pricing.candle_ws_poll_interval_ms must be > 0");
260 }
261 if self.pricing.min_settlement_samples == 0 {
262 bail!("pricing.min_settlement_samples must be > 0");
263 }
264 if self.pricing.hypercore_update_interval_secs == 0 {
265 bail!("pricing.hypercore_update_interval_secs must be > 0");
266 }
267 if self.pricing.oracle_symbol.trim().is_empty() {
268 bail!("pricing.oracle_symbol must not be empty");
269 }
270 if self.liquidation.enabled
271 && !self.rsm_signer.is_configured()
272 && self.transaction_submitter.mode != TransactionSubmitterMode::Direct
273 {
274 bail!("liquidation.enabled requires rsm_signer.aws_kms_key_id or transaction_submitter.mode=direct");
275 }
276 if self.onchain_deposits.poll_interval_ms == 0 {
277 bail!("onchain_deposits.poll_interval_ms must be > 0");
278 }
279 if self.onchain_deposits.startup_lookback_blocks == 0 {
280 bail!("onchain_deposits.startup_lookback_blocks must be > 0");
281 }
282 if self.onchain_deposits.max_blocks_per_poll == 0 {
283 bail!("onchain_deposits.max_blocks_per_poll must be > 0");
284 }
285 if self.onchain_deposits.max_blocks_per_get_logs == 0 {
286 bail!("onchain_deposits.max_blocks_per_get_logs must be > 0");
287 }
288 if self.liquidation.health_poll_interval_ms == 0 {
289 bail!("liquidation.health_poll_interval_ms must be > 0");
290 }
291 if self.liquidation.partial_reprice_interval_ms == 0 {
292 bail!("liquidation.partial_reprice_interval_ms must be > 0");
293 }
294 if self.liquidation.partial_bonus_max_bps < self.liquidation.partial_bonus_start_bps {
295 bail!(
296 "liquidation.partial_bonus_max_bps must be >= liquidation.partial_bonus_start_bps"
297 );
298 }
299 if self.liquidation.partial_bonus_ramp_ms == 0 {
300 bail!("liquidation.partial_bonus_ramp_ms must be > 0");
301 }
302 if self.liquidation.full_escalation_timeout_ms == 0 {
303 bail!("liquidation.full_escalation_timeout_ms must be > 0");
304 }
305 if self.liquidation.chain_observer_poll_interval_ms == 0 {
306 bail!("liquidation.chain_observer_poll_interval_ms must be > 0");
307 }
308 if self.liquidation.chain_observer_max_lag_blocks == 0 {
309 bail!("liquidation.chain_observer_max_lag_blocks must be > 0");
310 }
311 if self.catalog_manager.interval_secs == 0 {
312 bail!("catalog_manager.interval_secs must be > 0");
313 }
314 if self.engine.persistence.journal.batch_channel_capacity == 0 {
315 bail!("engine.persistence.journal.batch_channel_capacity must be > 0");
316 }
317 if self.engine.persistence.journal.batch_size == 0 {
318 bail!("engine.persistence.journal.batch_size must be > 0");
319 }
320 if self.engine.persistence.journal.flush_interval_ms == 0 {
321 bail!("engine.persistence.journal.flush_interval_ms must be > 0");
322 }
323 if self.engine.persistence.outbox.poll_ms == 0 {
324 bail!("engine.persistence.outbox.poll_ms must be > 0");
325 }
326 if self.engine.persistence.outbox.batch_size == 0 {
327 bail!("engine.persistence.outbox.batch_size must be > 0");
328 }
329 if self.engine.snapshot_interval_secs == 0 {
330 bail!("engine.snapshot_interval_secs must be > 0");
331 }
332 if self.engine.read_snapshot_interval_ms == 0
333 && self.engine.persistence.journal.wal_path.is_some()
334 {
335 bail!(
336 "engine.read_snapshot_interval_ms must be > 0 when engine.persistence.journal.wal_path is set"
337 );
338 }
339 if self.background_tasks.bbo_snapshot.interval_secs == 0 {
340 bail!("background_tasks.bbo_snapshot.interval_secs must be > 0");
341 }
342 if self.background_tasks.bbo_snapshot.retention_days <= 0 {
343 bail!("background_tasks.bbo_snapshot.retention_days must be > 0");
344 }
345 if self.background_tasks.historical_pnl.max_periods <= 0 {
346 bail!("background_tasks.historical_pnl.max_periods must be > 0");
347 }
348 if self.background_tasks.historical_pnl.capture_every_5m_ms <= 0
349 || self.background_tasks.historical_pnl.capture_every_1h_ms <= 0
350 || self.background_tasks.historical_pnl.capture_every_1d_ms <= 0
351 {
352 bail!("background_tasks.historical_pnl capture intervals must be > 0");
353 }
354 if !(0.0..=1.0).contains(&self.observability.tracing.sample_ratio) {
355 bail!("observability.tracing.sample_ratio must be between 0.0 and 1.0");
356 }
357 self.catalog.validate().context("Invalid catalog config")?;
358 Ok(())
359 }
360}
361
362fn mode_requires_contract_validation(secret_validation: SecretValidationMode) -> bool {
363 matches!(secret_validation, SecretValidationMode::FullRuntime)
364}
365
366#[derive(Debug, Clone, Default)]
367pub struct BackendSecrets {
368 pub database_url: Option<String>,
369 pub direct_url: Option<String>,
370 pub redis_url: Option<String>,
371 pub hydromancer_api_key: Option<String>,
372 pub hydromancer_ws_api_key: Option<String>,
373 pub transaction_submitter_private_key: Option<String>,
374 pub admin_api_key: Option<String>,
375}
376
377impl BackendSecrets {
378 pub fn from_env() -> Self {
379 Self {
380 database_url: std::env::var("DATABASE_URL").ok(),
381 direct_url: std::env::var("DIRECT_URL").ok(),
382 redis_url: std::env::var("REDIS_URL").ok(),
383 hydromancer_api_key: std::env::var("HYDROMANCER_API_KEY").ok(),
384 hydromancer_ws_api_key: std::env::var("HYDROMANCER_WS_API_KEY").ok(),
388 transaction_submitter_private_key: std::env::var("TRANSACTION_SUBMITTER_PRIVATE_KEY")
389 .ok(),
390 admin_api_key: std::env::var("ADMIN_API_KEY").ok(),
391 }
392 }
393
394 pub fn validate(&self, config: &BackendConfig) -> Result<()> {
395 self.validate_for_mode(config, SecretValidationMode::FullRuntime)
396 }
397
398 fn validate_for_mode(
399 &self,
400 config: &BackendConfig,
401 secret_validation: SecretValidationMode,
402 ) -> Result<()> {
403 match secret_validation {
404 SecretValidationMode::ConfigOnly => {}
405 SecretValidationMode::Migrations => {
406 self.direct_or_database_url()?;
407 }
408 SecretValidationMode::DatabaseOnly => {
409 self.require_database_url()?;
410 }
411 SecretValidationMode::FullRuntime => {
412 if config.environment.name != "development" {
416 self.require_admin_api_key()?;
417 }
418 if config.hydromancer.enabled {
419 self.require_hydromancer_api_key()?;
420 }
421 match config.transaction_submitter.mode {
422 TransactionSubmitterMode::Mock => {}
423 TransactionSubmitterMode::Direct => {
424 self.require_transaction_submitter_private_key()?;
425 }
426 TransactionSubmitterMode::AwsKms => {
427 if config
428 .transaction_submitter
429 .aws_kms_key_id
430 .trim()
431 .is_empty()
432 {
433 bail!("transaction_submitter.mode=aws_kms requires transaction_submitter.aws_kms_key_id");
434 }
435 #[cfg(not(feature = "aws"))]
436 bail!(
437 "transaction_submitter.mode=aws_kms requires building hypercall with the aws feature"
438 );
439 }
440 }
441 if (config.liquidation.enabled
442 || config.rsm_signer.is_configured()
443 || config.rsm_signer.provider == RsmSignerProvider::AwsKms)
444 && !uses_local_rsm_signer(config)
445 {
446 match config.rsm_signer.provider {
447 RsmSignerProvider::Local => {
448 self.require_transaction_submitter_private_key()?;
449 }
450 RsmSignerProvider::AwsKms => {
451 if config.rsm_signer.aws_kms_key_id.trim().is_empty() {
452 bail!("rsm_signer.provider=aws_kms requires rsm_signer.aws_kms_key_id");
453 }
454 #[cfg(not(feature = "aws"))]
455 bail!(
456 "rsm_signer.provider=aws_kms requires building hypercall with the aws feature"
457 );
458 }
459 }
460 }
461 if config.liquidation.enabled
462 && config.transaction_submitter.mode == TransactionSubmitterMode::Mock
463 {
464 bail!("liquidation.enabled requires a non-mock transaction_submitter.mode");
465 }
466 }
467 }
468 Ok(())
469 }
470
471 pub fn require_database_url(&self) -> Result<&str> {
472 self.database_url
473 .as_deref()
474 .context("DATABASE_URL must be set in the environment")
475 }
476
477 pub fn direct_or_database_url(&self) -> Result<&str> {
478 self.direct_url
479 .as_deref()
480 .or(self.database_url.as_deref())
481 .context("DIRECT_URL or DATABASE_URL must be set in the environment")
482 }
483
484 pub fn require_hydromancer_api_key(&self) -> Result<&str> {
485 self.hydromancer_api_key
486 .as_deref()
487 .context("HYDROMANCER_API_KEY must be set in the environment")
488 }
489
490 pub fn require_transaction_submitter_private_key(&self) -> Result<&str> {
491 self.transaction_submitter_private_key
492 .as_deref()
493 .context("TRANSACTION_SUBMITTER_PRIVATE_KEY must be set in the environment")
494 }
495
496 pub fn require_admin_api_key(&self) -> Result<&str> {
497 let key = self
498 .admin_api_key
499 .as_deref()
500 .context("ADMIN_API_KEY must be set outside the development environment")?;
501 if key.trim().is_empty() {
502 bail!("ADMIN_API_KEY must not be empty outside the development environment");
503 }
504 Ok(key)
505 }
506
507 pub fn transaction_submitter_secrets(
508 &self,
509 ) -> hypercall_transaction_submitter::TransactionSubmitterSecrets {
510 hypercall_transaction_submitter::TransactionSubmitterSecrets {
511 transaction_submitter_private_key: self.transaction_submitter_private_key.clone(),
512 }
513 }
514}
515
516fn uses_local_rsm_signer(config: &BackendConfig) -> bool {
517 config.rsm_signer.provider == RsmSignerProvider::Local
518 && config.transaction_submitter.mode == TransactionSubmitterMode::Direct
519}
520
521#[derive(Debug, Clone, Deserialize)]
522#[serde(default, deny_unknown_fields)]
523pub struct EnvironmentConfig {
524 pub name: String,
525}
526
527impl Default for EnvironmentConfig {
528 fn default() -> Self {
529 Self {
530 name: "development".to_string(),
531 }
532 }
533}
534
535#[derive(Debug, Clone, Deserialize)]
536#[serde(default, deny_unknown_fields)]
537pub struct ModesConfig {
538 pub testnet_mode: bool,
539 pub enable_test_endpoints: bool,
540 pub skip_external_oracle: bool,
541}
542
543impl Default for ModesConfig {
544 fn default() -> Self {
545 Self {
546 testnet_mode: false,
547 enable_test_endpoints: false,
548 skip_external_oracle: true,
549 }
550 }
551}
552
553#[derive(Debug, Clone, Deserialize, Default)]
554#[serde(default, deny_unknown_fields)]
555pub struct ContractsConfig {
556 pub exchange_contract_address: String,
557 pub core_deposit_wallet_address: String,
558 pub usdc_address: String,
559 pub option_registry_address: String,
560 pub option_token_beacon_proxy_init_code_hash: String,
561}
562
563impl ContractsConfig {
564 fn validate(&self) -> Result<()> {
565 let exchange_contract_address = self.exchange_contract_address.trim();
566 if exchange_contract_address.is_empty() {
567 bail!("contracts.exchange_contract_address must be set");
568 }
569 Address::from_str(exchange_contract_address).map_err(|err| {
570 anyhow::anyhow!(
571 "contracts.exchange_contract_address must be a valid EVM address: {}",
572 err
573 )
574 })?;
575
576 let core_deposit_wallet_address = self.core_deposit_wallet_address.trim();
577 if core_deposit_wallet_address.is_empty() {
578 bail!("contracts.core_deposit_wallet_address must be set");
579 }
580 Address::from_str(core_deposit_wallet_address).map_err(|err| {
581 anyhow::anyhow!(
582 "contracts.core_deposit_wallet_address must be a valid EVM address: {}",
583 err
584 )
585 })?;
586
587 let usdc_address = self.usdc_address.trim();
588 if usdc_address.is_empty() {
589 bail!("contracts.usdc_address must be set");
590 }
591 Address::from_str(usdc_address).map_err(|err| {
592 anyhow::anyhow!(
593 "contracts.usdc_address must be a valid EVM address: {}",
594 err
595 )
596 })?;
597
598 let option_registry_address = self.option_registry_address.trim();
599 if option_registry_address.is_empty() {
600 bail!("contracts.option_registry_address must be set");
601 }
602
603 let option_token_beacon_proxy_init_code_hash =
604 self.option_token_beacon_proxy_init_code_hash.trim();
605 if option_token_beacon_proxy_init_code_hash.is_empty() {
606 bail!("contracts.option_token_beacon_proxy_init_code_hash must be set");
607 }
608
609 self.option_token_deployment()?;
610 Ok(())
611 }
612
613 fn option_token_deployment(&self) -> Result<hypercall_types::OptionTokenDeployment> {
614 let option_registry =
615 Address::from_str(self.option_registry_address.trim()).map_err(|err| {
616 anyhow::anyhow!(
617 "contracts.option_registry_address must be a valid EVM address: {}",
618 err
619 )
620 })?;
621 let beacon_proxy_init_code_hash = B256::from_str(
622 self.option_token_beacon_proxy_init_code_hash.trim(),
623 )
624 .map_err(|err| {
625 anyhow::anyhow!(
626 "contracts.option_token_beacon_proxy_init_code_hash must be a valid B256 hex string: {}",
627 err
628 )
629 })?;
630
631 Ok(hypercall_types::OptionTokenDeployment::new(
632 option_registry,
633 beacon_proxy_init_code_hash,
634 ))
635 }
636}
637
638#[derive(Debug, Clone, Deserialize, Default)]
639#[serde(default, deny_unknown_fields)]
640pub struct DatabaseConfig {
641 pub pool: DatabasePoolConfig,
642}
643
644#[derive(Debug, Clone, Deserialize)]
645#[serde(default, deny_unknown_fields)]
646pub struct DatabasePoolConfig {
647 pub diesel_max: u32,
648 pub journal_max: u32,
649}
650
651impl Default for DatabasePoolConfig {
652 fn default() -> Self {
653 Self {
654 diesel_max: 3,
655 journal_max: 2,
656 }
657 }
658}
659
660#[derive(Debug, Clone, Deserialize)]
661#[serde(default, deny_unknown_fields)]
662pub struct HydromancerRuntimeConfig {
663 pub enabled: bool,
664 pub api_url: String,
666 pub ws_url: Option<String>,
668 pub ws_api_key: Option<String>,
670}
671
672impl Default for HydromancerRuntimeConfig {
673 fn default() -> Self {
674 Self {
675 enabled: false,
676 api_url: "https://api.hydromancer.xyz/info".to_string(),
677 ws_url: None,
678 ws_api_key: None,
679 }
680 }
681}
682
683#[derive(Debug, Clone, Deserialize)]
684#[serde(default, deny_unknown_fields)]
685pub struct RsmSignerConfig {
686 pub provider: RsmSignerProvider,
687 pub aws_kms_key_id: String,
688}
689
690#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
691#[serde(rename_all = "snake_case")]
692pub enum RsmSignerProvider {
693 Local,
694 AwsKms,
695}
696
697impl FromStr for RsmSignerProvider {
698 type Err = String;
699
700 fn from_str(value: &str) -> Result<Self, Self::Err> {
701 match value.trim().to_ascii_lowercase().as_str() {
702 "local" | "direct" => Ok(Self::Local),
703 "aws_kms" => Ok(Self::AwsKms),
704 other => Err(format!("expected one of: local, aws_kms; got {}", other)),
705 }
706 }
707}
708
709impl Default for RsmSignerConfig {
710 fn default() -> Self {
711 Self {
712 provider: RsmSignerProvider::Local,
713 aws_kms_key_id: String::new(),
714 }
715 }
716}
717
718impl RsmSignerConfig {
719 pub fn is_configured(&self) -> bool {
720 !self.aws_kms_key_id.trim().is_empty()
721 }
722}
723
724#[derive(Debug, Clone, Deserialize)]
725#[serde(default, deny_unknown_fields)]
726pub struct LiquidationRuntimeConfig {
727 pub enabled: bool,
728 pub health_poll_interval_ms: u64,
729 pub partial_target_buffer_bps: u32,
730 pub partial_reprice_interval_ms: u64,
731 pub partial_bonus_start_bps: u32,
732 pub partial_bonus_max_bps: u32,
733 pub partial_bonus_ramp_ms: u64,
734 pub min_shortfall_threshold: rust_decimal::Decimal,
735 pub full_escalation_timeout_ms: u64,
736 pub full_target_buffer_bps: u32,
737 pub chain_observer_poll_interval_ms: u64,
738 pub chain_observer_max_lag_blocks: u64,
739}
740
741impl Default for LiquidationRuntimeConfig {
742 fn default() -> Self {
743 Self {
744 enabled: false,
745 health_poll_interval_ms: 5_000,
746 partial_target_buffer_bps: 500,
747 partial_reprice_interval_ms: 5_000,
748 partial_bonus_start_bps: 50,
749 partial_bonus_max_bps: 1_000,
750 partial_bonus_ramp_ms: 300_000,
751 min_shortfall_threshold: rust_decimal::Decimal::ZERO,
752 full_escalation_timeout_ms: 60_000,
753 full_target_buffer_bps: 500,
754 chain_observer_poll_interval_ms: 2_000,
755 chain_observer_max_lag_blocks: 32,
756 }
757 }
758}
759
760#[derive(Debug, Clone, Deserialize)]
761#[serde(default, deny_unknown_fields)]
762pub struct OnchainDepositsRuntimeConfig {
763 pub poll_interval_ms: u64,
764 pub startup_lookback_blocks: u64,
765 pub max_blocks_per_poll: u64,
766 pub max_blocks_per_get_logs: u64,
767 pub confirmation_blocks: u64,
768 pub pm_liquidity_settlement_grace_ms: i64,
769 pub ws_url: Option<String>,
772}
773
774impl Default for OnchainDepositsRuntimeConfig {
775 fn default() -> Self {
776 Self {
777 poll_interval_ms: 2_000,
778 startup_lookback_blocks: 32,
779 max_blocks_per_poll: 1_000,
780 max_blocks_per_get_logs: 500,
781 confirmation_blocks: 2,
782 pm_liquidity_settlement_grace_ms: 0,
783 ws_url: None,
784 }
785 }
786}
787
788#[derive(Debug, Clone, Deserialize)]
789#[serde(default, deny_unknown_fields)]
790pub struct CatalogManagerRuntimeConfig {
791 pub enabled: bool,
792 pub interval_secs: u64,
793}
794
795impl Default for CatalogManagerRuntimeConfig {
796 fn default() -> Self {
797 Self {
798 enabled: false,
799 interval_secs: 3600,
800 }
801 }
802}
803
804#[derive(Debug, Clone, Deserialize)]
805#[serde(default, deny_unknown_fields)]
806pub struct EngineRuntimeConfig {
807 pub persistence: EnginePersistenceConfig,
808 pub snapshot_interval_secs: u64,
809 pub post_startup_reconcile_delay_secs: u64,
810 pub read_snapshot_interval_ms: u64,
811 pub allow_standard_margin_shorts: bool,
812 pub portfolio_margin_mode_allowlist: Vec<String>,
813 pub portfolio_margin_pool_enabled: bool,
814 pub portfolio_margin_settlement_allowlist: Vec<String>,
815}
816
817impl Default for EngineRuntimeConfig {
818 fn default() -> Self {
819 Self {
820 persistence: EnginePersistenceConfig::default(),
821 snapshot_interval_secs: 60,
822 post_startup_reconcile_delay_secs: 5,
823 read_snapshot_interval_ms: 500,
824 allow_standard_margin_shorts: false,
825 portfolio_margin_mode_allowlist: Vec::new(),
826 portfolio_margin_pool_enabled: false,
827 portfolio_margin_settlement_allowlist: Vec::new(),
828 }
829 }
830}
831
832#[derive(Debug, Clone, Deserialize, Default)]
833#[serde(default, deny_unknown_fields)]
834pub struct EnginePersistenceConfig {
835 pub journal: JournalRuntimeConfig,
836 pub outbox: OutboxRuntimeConfig,
837}
838
839#[derive(Debug, Clone, Deserialize)]
840#[serde(default, deny_unknown_fields)]
841pub struct JournalRuntimeConfig {
842 pub event_persistence: String,
843 pub critical_event_types: Vec<String>,
844 pub wal_path: Option<PathBuf>,
845 pub batch_channel_capacity: usize,
846 pub batch_size: usize,
847 pub flush_interval_ms: u64,
848 pub digests_enabled: bool,
849 pub rsm_environment: RsmEnvironmentConfig,
850 #[cfg(feature = "rsm-state")]
851 pub rsm_state: RsmStateRuntimeConfig,
852}
853
854impl Default for JournalRuntimeConfig {
855 fn default() -> Self {
856 Self {
857 event_persistence: "full".to_string(),
858 critical_event_types: vec!["L2Update".to_string()],
859 wal_path: None,
860 batch_channel_capacity: 1024,
861 batch_size: 100,
862 flush_interval_ms: 10,
863 digests_enabled: true,
864 rsm_environment: RsmEnvironmentConfig::Development,
865 #[cfg(feature = "rsm-state")]
866 rsm_state: RsmStateRuntimeConfig::default(),
867 }
868 }
869}
870
871#[cfg(feature = "rsm-state")]
872#[derive(Debug, Clone, Deserialize)]
873#[serde(default, deny_unknown_fields)]
874pub struct RsmStateRuntimeConfig {
875 pub state_store_path: PathBuf,
876 pub prune_window_versions: u64,
877 pub pruning_enabled: bool,
878}
879
880#[cfg(feature = "rsm-state")]
881impl Default for RsmStateRuntimeConfig {
882 fn default() -> Self {
883 Self {
884 state_store_path: PathBuf::from("target/validator-rsm-state"),
885 prune_window_versions: 10_000,
886 pruning_enabled: false,
887 }
888 }
889}
890
891#[derive(Debug, Clone, Deserialize)]
892#[serde(default, deny_unknown_fields)]
893pub struct OutboxRuntimeConfig {
894 pub poll_ms: u64,
895 pub batch_size: i64,
896 pub max_drain_iterations: usize,
897 pub drain_timeout_secs: u64,
898 pub skip_drain: bool,
899}
900
901impl Default for OutboxRuntimeConfig {
902 fn default() -> Self {
903 Self {
904 poll_ms: 10,
905 batch_size: 500,
906 max_drain_iterations: 0,
907 drain_timeout_secs: 30,
908 skip_drain: false,
909 }
910 }
911}
912
913#[derive(Debug, Clone, Deserialize, Default)]
914#[serde(default, deny_unknown_fields)]
915pub struct BackgroundTasksConfig {
916 pub historical_pnl: HistoricalPnlRuntimeConfig,
917 pub bbo_snapshot: BboSnapshotRuntimeConfig,
918}
919
920#[derive(Debug, Clone, Deserialize)]
921#[serde(default, deny_unknown_fields)]
922pub struct HistoricalPnlRuntimeConfig {
923 pub max_periods: i64,
924 pub capture_every_5m_ms: i64,
925 pub capture_every_1h_ms: i64,
926 pub capture_every_1d_ms: i64,
927}
928
929impl Default for HistoricalPnlRuntimeConfig {
930 fn default() -> Self {
931 Self {
932 max_periods: 100,
933 capture_every_5m_ms: hypercall_types::HISTORICAL_PNL_INTERVAL_5M_MS,
934 capture_every_1h_ms: hypercall_types::HISTORICAL_PNL_INTERVAL_1H_MS,
935 capture_every_1d_ms: hypercall_types::HISTORICAL_PNL_INTERVAL_1D_MS,
936 }
937 }
938}
939
940#[derive(Debug, Clone, Deserialize)]
941#[serde(default, deny_unknown_fields)]
942pub struct BboSnapshotRuntimeConfig {
943 pub interval_secs: u64,
944 pub retention_days: i64,
945}
946
947impl Default for BboSnapshotRuntimeConfig {
948 fn default() -> Self {
949 Self {
950 interval_secs: 300,
951 retention_days: 7,
952 }
953 }
954}
955
956#[derive(Debug, Clone, Deserialize, Default)]
957#[serde(default, deny_unknown_fields)]
958pub struct ObservabilityRuntimeConfig {
959 pub tracing: TracingRuntimeConfig,
960 pub pyroscope: PyroscopeRuntimeConfig,
961 pub metrics: MetricsRuntimeConfig,
962}
963
964#[derive(Debug, Clone, Deserialize)]
965#[serde(default, deny_unknown_fields)]
966pub struct TracingRuntimeConfig {
967 pub enabled: bool,
968 pub otlp_endpoint: String,
969 pub service_name: String,
970 pub sample_ratio: f64,
971}
972
973impl Default for TracingRuntimeConfig {
974 fn default() -> Self {
975 Self {
976 enabled: true,
977 otlp_endpoint: "http://localhost:4317".to_string(),
978 service_name: "hypercall".to_string(),
979 sample_ratio: 1.0,
980 }
981 }
982}
983
984#[derive(Debug, Clone, Deserialize)]
985#[serde(default, deny_unknown_fields)]
986pub struct PyroscopeRuntimeConfig {
987 pub enabled: bool,
988 pub server_address: Option<String>,
989 pub application_name: String,
990}
991
992impl Default for PyroscopeRuntimeConfig {
993 fn default() -> Self {
994 Self {
995 enabled: true,
996 server_address: None,
997 application_name: "hypercall".to_string(),
998 }
999 }
1000}
1001
1002#[derive(Debug, Clone, Deserialize, Default)]
1003#[serde(default, deny_unknown_fields)]
1004pub struct MetricsRuntimeConfig {
1005 pub skip_db_queries: bool,
1006}
1007
1008fn default_catalog() -> CatalogConfig {
1009 serde_yaml_ng::from_str(include_str!(concat!(
1010 env!("CARGO_MANIFEST_DIR"),
1011 "/../../market_catalog.yaml"
1012 )))
1013 .expect("bundled market catalog must deserialize")
1014}
1015
1016fn legacy_catalog_path(testnet_mode: bool) -> String {
1017 env_string("CATALOG_MANAGER_CATALOG_PATH").unwrap_or_else(|| {
1018 if testnet_mode {
1019 "./market_catalog_dev.yaml".to_string()
1020 } else {
1021 "./market_catalog.yaml".to_string()
1022 }
1023 })
1024}
1025
1026fn load_legacy_catalog_config(testnet_mode: bool) -> Result<CatalogConfig> {
1027 let catalog_path = legacy_catalog_path(testnet_mode);
1028 crate::catalog_manager::load_catalog_config(&catalog_path)
1029 .with_context(|| format!("Failed to load catalog config from {}", catalog_path))
1030}
1031
1032fn apply_legacy_env_overrides(config: &mut BackendConfig, load_catalog: bool) -> Result<()> {
1033 config.environment.name =
1034 env_string("ENVIRONMENT").unwrap_or_else(|| config.environment.name.clone());
1035
1036 override_env_bool("TESTNET_MODE", &mut config.modes.testnet_mode)?;
1037 override_env_bool(
1038 "ENABLE_TEST_ENDPOINTS",
1039 &mut config.modes.enable_test_endpoints,
1040 )?;
1041 override_env_bool(
1042 "SKIP_EXTERNAL_ORACLE",
1043 &mut config.modes.skip_external_oracle,
1044 )?;
1045 apply_legacy_testnet_pricing_defaults(config);
1046 override_env_string(
1047 "EXCHANGE_CONTRACT_ADDRESS",
1048 &mut config.contracts.exchange_contract_address,
1049 );
1050 override_env_string(
1051 "CORE_DEPOSIT_WALLET_ADDRESS",
1052 &mut config.contracts.core_deposit_wallet_address,
1053 );
1054 override_env_string("USDC_ADDRESS", &mut config.contracts.usdc_address);
1055 override_env_string(
1056 "OPTION_REGISTRY_ADDRESS",
1057 &mut config.contracts.option_registry_address,
1058 );
1059 override_env_string(
1060 "OPTION_TOKEN_BEACON_PROXY_INIT_CODE_HASH",
1061 &mut config.contracts.option_token_beacon_proxy_init_code_hash,
1062 );
1063
1064 override_env_parse("DB_POOL_MAX_DIESEL", &mut config.database.pool.diesel_max)?;
1065 override_env_parse("DB_POOL_MAX_JOURNAL", &mut config.database.pool.journal_max)?;
1066
1067 override_env_parse(
1068 "MAX_OPEN_ORDERS_DEFAULT",
1069 &mut config.api.max_open_orders_default,
1070 )?;
1071 override_env_parse(
1072 "MAX_OPEN_POSITIONS_DEFAULT",
1073 &mut config.api.max_open_positions_default,
1074 )?;
1075 override_env_bool("TRADING_HALTED", &mut config.api.global_trading_halted)?;
1076 override_env_csv("HALTED_MARKETS", &mut config.api.halted_markets);
1077 override_env_option_string(
1078 "TRADE_EXPLORER_URL_TEMPLATE",
1079 &mut config.api.trade_explorer_url_template,
1080 );
1081 override_env_parse(
1082 "HT_WS_HEARTBEAT_INTERVAL_MS",
1083 &mut config.api.ws_heartbeat_interval_ms,
1084 )?;
1085 override_env_parse("HT_WS_PONG_TIMEOUT_MS", &mut config.api.ws_pong_timeout_ms)?;
1086 override_env_parse(
1087 "MARKETS_SNAPSHOT_REFRESH_MS",
1088 &mut config.api.markets_snapshot_refresh_ms,
1089 )?;
1090 override_env_bool(
1091 "HT_REQUIRE_RISK_VOL_READINESS",
1092 &mut config.api.require_risk_vol_readiness,
1093 )?;
1094
1095 override_env_string(
1096 "HYPERLIQUID_INFO_URL",
1097 &mut config.pricing.hyperliquid_info_url,
1098 );
1099 override_env_string("HYPERLIQUID_WS_URL", &mut config.pricing.hyperliquid_ws_url);
1100 override_env_parse(
1101 "CANDLE_WS_POLL_INTERVAL_MS",
1102 &mut config.pricing.candle_ws_poll_interval_ms,
1103 )?;
1104 override_env_parse(
1105 "MIN_SETTLEMENT_SAMPLES",
1106 &mut config.pricing.min_settlement_samples,
1107 )?;
1108 override_env_string("HYPERCORE_INFO_URL", &mut config.pricing.hypercore_info_url);
1109 override_env_parse(
1110 "HYPERCORE_UPDATE_INTERVAL_SECS",
1111 &mut config.pricing.hypercore_update_interval_secs,
1112 )?;
1113 override_env_string("ORACLE_SYMBOL", &mut config.pricing.oracle_symbol);
1114
1115 override_env_bool("HYDROMANCER_ENABLED", &mut config.hydromancer.enabled)?;
1116 override_env_string("HYDROMANCER_API_URL", &mut config.hydromancer.api_url);
1117 if let Ok(val) = std::env::var("HYDROMANCER_WS_URL") {
1118 if !val.trim().is_empty() {
1119 config.hydromancer.ws_url = Some(val);
1120 }
1121 }
1122
1123 config.transaction_submitter.mode =
1124 transaction_submitter_mode_from_env(config.transaction_submitter.mode)?;
1125 override_env_string(
1126 "TRANSACTION_SUBMITTER_AWS_KMS_KEY_ID",
1127 &mut config.transaction_submitter.aws_kms_key_id,
1128 );
1129 let rsm_signer_provider_from_env = env_string("RSM_SIGNER_PROVIDER");
1130 if let Some(provider) = &rsm_signer_provider_from_env {
1131 config.rsm_signer.provider = provider.parse().map_err(|error| {
1132 anyhow::anyhow!(
1133 "Invalid value for environment variable RSM_SIGNER_PROVIDER: {}",
1134 error
1135 )
1136 })?;
1137 }
1138 let rsm_signer_kms_key_from_env = env_string("RSM_SIGNER_AWS_KMS_KEY_ID");
1139 override_env_string(
1140 "RSM_SIGNER_AWS_KMS_KEY_ID",
1141 &mut config.rsm_signer.aws_kms_key_id,
1142 );
1143 if rsm_signer_provider_from_env.is_none() && rsm_signer_kms_key_from_env.is_some() {
1144 config.rsm_signer.provider = RsmSignerProvider::AwsKms;
1145 }
1146 override_env_string(
1147 "MAX_GAS_PRICE",
1148 &mut config.transaction_submitter.max_gas_price,
1149 );
1150 override_env_string("RPC_URL", &mut config.transaction_submitter.rpc_url);
1151 override_env_bool("LIQUIDATION_ENABLED", &mut config.liquidation.enabled)?;
1152 override_env_parse(
1153 "LIQUIDATION_HEALTH_POLL_INTERVAL_MS",
1154 &mut config.liquidation.health_poll_interval_ms,
1155 )?;
1156 override_env_parse(
1157 "LIQUIDATION_PARTIAL_TARGET_BUFFER_BPS",
1158 &mut config.liquidation.partial_target_buffer_bps,
1159 )?;
1160 override_env_parse(
1161 "LIQUIDATION_PARTIAL_REPRICE_INTERVAL_MS",
1162 &mut config.liquidation.partial_reprice_interval_ms,
1163 )?;
1164 override_env_parse(
1165 "LIQUIDATION_PARTIAL_BONUS_START_BPS",
1166 &mut config.liquidation.partial_bonus_start_bps,
1167 )?;
1168 override_env_parse(
1169 "LIQUIDATION_PARTIAL_BONUS_MAX_BPS",
1170 &mut config.liquidation.partial_bonus_max_bps,
1171 )?;
1172 override_env_parse(
1173 "LIQUIDATION_PARTIAL_BONUS_RAMP_MS",
1174 &mut config.liquidation.partial_bonus_ramp_ms,
1175 )?;
1176 override_env_parse(
1177 "LIQUIDATION_MIN_SHORTFALL_THRESHOLD",
1178 &mut config.liquidation.min_shortfall_threshold,
1179 )?;
1180 override_env_parse(
1181 "LIQUIDATION_FULL_ESCALATION_TIMEOUT_MS",
1182 &mut config.liquidation.full_escalation_timeout_ms,
1183 )?;
1184 override_env_parse(
1185 "LIQUIDATION_FULL_TARGET_BUFFER_BPS",
1186 &mut config.liquidation.full_target_buffer_bps,
1187 )?;
1188 override_env_parse(
1189 "LIQUIDATION_CHAIN_OBSERVER_POLL_INTERVAL_MS",
1190 &mut config.liquidation.chain_observer_poll_interval_ms,
1191 )?;
1192 override_env_parse(
1193 "LIQUIDATION_CHAIN_OBSERVER_MAX_LAG_BLOCKS",
1194 &mut config.liquidation.chain_observer_max_lag_blocks,
1195 )?;
1196 override_env_parse(
1197 "ONCHAIN_DEPOSITS_POLL_INTERVAL_MS",
1198 &mut config.onchain_deposits.poll_interval_ms,
1199 )?;
1200 override_env_parse(
1201 "ONCHAIN_DEPOSITS_STARTUP_LOOKBACK_BLOCKS",
1202 &mut config.onchain_deposits.startup_lookback_blocks,
1203 )?;
1204 override_env_parse(
1205 "ONCHAIN_DEPOSITS_MAX_BLOCKS_PER_POLL",
1206 &mut config.onchain_deposits.max_blocks_per_poll,
1207 )?;
1208 override_env_parse(
1209 "ONCHAIN_DEPOSITS_MAX_BLOCKS_PER_GET_LOGS",
1210 &mut config.onchain_deposits.max_blocks_per_get_logs,
1211 )?;
1212 override_env_parse(
1213 "ONCHAIN_DEPOSITS_CONFIRMATION_BLOCKS",
1214 &mut config.onchain_deposits.confirmation_blocks,
1215 )?;
1216 override_env_parse(
1217 "ONCHAIN_DEPOSITS_PM_LIQUIDITY_SETTLEMENT_GRACE_MS",
1218 &mut config.onchain_deposits.pm_liquidity_settlement_grace_ms,
1219 )?;
1220 override_env_option_string(
1221 "ONCHAIN_DEPOSITS_WS_URL",
1222 &mut config.onchain_deposits.ws_url,
1223 );
1224
1225 override_env_bool(
1226 "CATALOG_MANAGER_ENABLED",
1227 &mut config.catalog_manager.enabled,
1228 )?;
1229 override_env_parse(
1230 "CATALOG_MANAGER_INTERVAL_SECS",
1231 &mut config.catalog_manager.interval_secs,
1232 )?;
1233 if load_catalog {
1234 config.catalog = load_legacy_catalog_config(config.modes.testnet_mode)?;
1235 }
1236
1237 override_env_string(
1238 "ENGINE_JOURNAL_EVENT_PERSISTENCE",
1239 &mut config.engine.persistence.journal.event_persistence,
1240 );
1241 override_env_csv(
1242 "ENGINE_JOURNAL_CRITICAL_EVENT_TYPES",
1243 &mut config.engine.persistence.journal.critical_event_types,
1244 );
1245 override_env_option_path(
1246 "ENGINE_JOURNAL_WAL_PATH",
1247 &mut config.engine.persistence.journal.wal_path,
1248 );
1249 override_env_parse(
1250 "ENGINE_JOURNAL_BATCH_CHANNEL_CAPACITY",
1251 &mut config.engine.persistence.journal.batch_channel_capacity,
1252 )?;
1253 override_env_parse(
1254 "ENGINE_JOURNAL_BATCH_SIZE",
1255 &mut config.engine.persistence.journal.batch_size,
1256 )?;
1257 override_env_parse(
1258 "ENGINE_JOURNAL_BATCH_FLUSH_MS",
1259 &mut config.engine.persistence.journal.flush_interval_ms,
1260 )?;
1261 override_env_bool(
1262 "ENGINE_JOURNAL_DIGESTS_ENABLED",
1263 &mut config.engine.persistence.journal.digests_enabled,
1264 )?;
1265 override_env_parse(
1266 "ENGINE_OUTBOX_POLL_MS",
1267 &mut config.engine.persistence.outbox.poll_ms,
1268 )?;
1269 override_env_parse(
1270 "ENGINE_OUTBOX_BATCH_SIZE",
1271 &mut config.engine.persistence.outbox.batch_size,
1272 )?;
1273 override_env_parse(
1274 "ENGINE_OUTBOX_MAX_DRAIN_ITERATIONS",
1275 &mut config.engine.persistence.outbox.max_drain_iterations,
1276 )?;
1277 override_env_parse(
1278 "ENGINE_OUTBOX_DRAIN_TIMEOUT_SECS",
1279 &mut config.engine.persistence.outbox.drain_timeout_secs,
1280 )?;
1281 override_env_bool(
1282 "ENGINE_OUTBOX_SKIP_DRAIN",
1283 &mut config.engine.persistence.outbox.skip_drain,
1284 )?;
1285 override_env_parse(
1286 "ENGINE_POST_STARTUP_RECONCILE_DELAY_SECS",
1287 &mut config.engine.post_startup_reconcile_delay_secs,
1288 )?;
1289 override_env_parse(
1290 "ENGINE_READ_SNAPSHOT_INTERVAL_MS",
1291 &mut config.engine.read_snapshot_interval_ms,
1292 )?;
1293 apply_portfolio_margin_pool_env_overrides(config)?;
1294 override_env_parse(
1295 "ENGINE_SNAPSHOT_INTERVAL_SECS",
1296 &mut config.engine.snapshot_interval_secs,
1297 )?;
1298
1299 override_env_parse(
1300 "HISTORICAL_PNL_MAX_PERIODS",
1301 &mut config.background_tasks.historical_pnl.max_periods,
1302 )?;
1303 override_env_parse(
1304 "HISTORICAL_PNL_CAPTURE_EVERY_5M_MS",
1305 &mut config.background_tasks.historical_pnl.capture_every_5m_ms,
1306 )?;
1307 override_env_parse(
1308 "HISTORICAL_PNL_CAPTURE_EVERY_1H_MS",
1309 &mut config.background_tasks.historical_pnl.capture_every_1h_ms,
1310 )?;
1311 override_env_parse(
1312 "HISTORICAL_PNL_CAPTURE_EVERY_1D_MS",
1313 &mut config.background_tasks.historical_pnl.capture_every_1d_ms,
1314 )?;
1315 override_env_parse(
1316 "BBO_SNAPSHOT_INTERVAL_SECS",
1317 &mut config.background_tasks.bbo_snapshot.interval_secs,
1318 )?;
1319 override_env_parse(
1320 "BBO_SNAPSHOT_RETENTION_DAYS",
1321 &mut config.background_tasks.bbo_snapshot.retention_days,
1322 )?;
1323
1324 override_env_bool("TRACING_ENABLED", &mut config.observability.tracing.enabled)?;
1325 override_env_string(
1326 "OTEL_EXPORTER_OTLP_ENDPOINT",
1327 &mut config.observability.tracing.otlp_endpoint,
1328 );
1329 override_env_string(
1330 "OTEL_SERVICE_NAME",
1331 &mut config.observability.tracing.service_name,
1332 );
1333 override_env_parse(
1334 "TRACING_SAMPLE_RATIO",
1335 &mut config.observability.tracing.sample_ratio,
1336 )?;
1337 override_env_bool(
1338 "PYROSCOPE_ENABLED",
1339 &mut config.observability.pyroscope.enabled,
1340 )?;
1341 override_env_option_string(
1342 "PYROSCOPE_SERVER_ADDRESS",
1343 &mut config.observability.pyroscope.server_address,
1344 );
1345 override_env_string(
1346 "PYROSCOPE_APPLICATION_NAME",
1347 &mut config.observability.pyroscope.application_name,
1348 );
1349 override_env_bool(
1350 "METRICS_SKIP_DB_QUERIES",
1351 &mut config.observability.metrics.skip_db_queries,
1352 )?;
1353
1354 Ok(())
1355}
1356
1357fn apply_legacy_testnet_pricing_defaults(config: &mut BackendConfig) {
1358 if !config.modes.testnet_mode {
1359 return;
1360 }
1361
1362 if env_string("HYPERLIQUID_INFO_URL").is_none() {
1363 config.pricing.hyperliquid_info_url = DEFAULT_HYPERLIQUID_TESTNET_INFO_URL.to_string();
1364 }
1365 if env_string("HYPERLIQUID_WS_URL").is_none() {
1366 config.pricing.hyperliquid_ws_url =
1367 hypercall_config::DEFAULT_HYPERLIQUID_TESTNET_WS_URL.to_string();
1368 }
1369 if env_string("HYPERCORE_INFO_URL").is_none() {
1370 config.pricing.hypercore_info_url = DEFAULT_HYPERLIQUID_TESTNET_INFO_URL.to_string();
1371 }
1372}
1373
1374fn transaction_submitter_mode_from_env(
1375 default_mode: TransactionSubmitterMode,
1376) -> Result<TransactionSubmitterMode> {
1377 if let Some(mode) = env_string("TRANSACTION_SUBMITTER_MODE") {
1378 return mode.parse().map_err(|error| {
1379 anyhow::anyhow!(
1380 "Invalid value for environment variable TRANSACTION_SUBMITTER_MODE: {}",
1381 error
1382 )
1383 });
1384 }
1385 legacy_transaction_submitter_mode_from_env(default_mode)
1386}
1387
1388fn legacy_transaction_submitter_mode_from_env(
1389 default_mode: TransactionSubmitterMode,
1390) -> Result<TransactionSubmitterMode> {
1391 if legacy_aws_kms_submitter_env_present() {
1392 return Ok(TransactionSubmitterMode::AwsKms);
1393 }
1394 if legacy_direct_submitter_env_present() {
1395 return Ok(TransactionSubmitterMode::Direct);
1396 }
1397 Ok(default_mode)
1398}
1399
1400fn legacy_direct_submitter_env_present() -> bool {
1401 env_string("TRANSACTION_SUBMITTER_PRIVATE_KEY").is_some()
1402 && ["RPC_URL", "MAX_GAS_PRICE"]
1403 .iter()
1404 .all(|name| env_string(name).is_some())
1405}
1406
1407fn legacy_aws_kms_submitter_env_present() -> bool {
1408 env_string("TRANSACTION_SUBMITTER_AWS_KMS_KEY_ID").is_some()
1409 && ["RPC_URL", "MAX_GAS_PRICE"]
1410 .iter()
1411 .all(|name| env_string(name).is_some())
1412}
1413
1414fn env_string(name: &str) -> Option<String> {
1415 std::env::var(name)
1416 .ok()
1417 .map(|value| value.trim().to_string())
1418 .filter(|value| !value.is_empty())
1419}
1420
1421fn override_env_string(name: &str, target: &mut String) {
1422 if let Some(value) = env_string(name) {
1423 *target = value;
1424 }
1425}
1426
1427fn override_env_option_string(name: &str, target: &mut Option<String>) {
1428 if let Some(value) = env_string(name) {
1429 *target = Some(value);
1430 }
1431}
1432
1433fn override_env_option_path(name: &str, target: &mut Option<PathBuf>) {
1434 if let Some(value) = env_string(name) {
1435 *target = Some(PathBuf::from(value));
1436 }
1437}
1438
1439fn override_env_csv(name: &str, target: &mut Vec<String>) {
1440 if let Some(value) = env_string(name) {
1441 *target = value
1442 .split(',')
1443 .map(str::trim)
1444 .filter(|value| !value.is_empty())
1445 .map(ToOwned::to_owned)
1446 .collect();
1447 }
1448}
1449
1450fn apply_portfolio_margin_pool_env_overrides(config: &mut BackendConfig) -> Result<()> {
1451 override_env_csv(
1452 "PORTFOLIO_MARGIN_MODE_ALLOWLIST",
1453 &mut config.engine.portfolio_margin_mode_allowlist,
1454 );
1455 override_env_bool(
1456 "PORTFOLIO_MARGIN_POOL_ENABLED",
1457 &mut config.engine.portfolio_margin_pool_enabled,
1458 )?;
1459 override_env_csv(
1460 "PORTFOLIO_MARGIN_SETTLEMENT_ALLOWLIST",
1461 &mut config.engine.portfolio_margin_settlement_allowlist,
1462 );
1463 Ok(())
1464}
1465
1466fn override_env_bool(name: &str, target: &mut bool) -> Result<()> {
1467 if let Some(value) = env_string(name) {
1468 *target = parse_env_bool(name, &value)?;
1469 }
1470 Ok(())
1471}
1472
1473fn override_env_parse<T>(name: &str, target: &mut T) -> Result<()>
1474where
1475 T: FromStr,
1476 T::Err: std::fmt::Display,
1477{
1478 if let Some(value) = env_string(name) {
1479 *target = value.parse::<T>().map_err(|err| {
1480 anyhow::anyhow!("Invalid value for environment variable {}: {}", name, err)
1481 })?;
1482 }
1483 Ok(())
1484}
1485
1486fn parse_env_bool(name: &str, value: &str) -> Result<bool> {
1487 match value.to_ascii_lowercase().as_str() {
1488 "1" | "true" | "yes" | "on" => Ok(true),
1489 "0" | "false" | "no" | "off" => Ok(false),
1490 _ => bail!(
1491 "Invalid boolean value '{}' for environment variable {}",
1492 value,
1493 name
1494 ),
1495 }
1496}
1497
1498#[cfg(test)]
1499mod tests {
1500 use super::*;
1501 use crate::test_contracts::{
1502 testnet_contracts_yaml_block, TESTNET_CORE_DEPOSIT_WALLET_ADDRESS,
1503 TESTNET_EXCHANGE_CONTRACT_ADDRESS, TESTNET_OPTION_REGISTRY_ADDRESS,
1504 TESTNET_OPTION_TOKEN_BEACON_PROXY_INIT_CODE_HASH, TESTNET_USDC_ADDRESS,
1505 };
1506 use rust_decimal::Decimal;
1507 use serial_test::serial;
1508 use std::ffi::OsString;
1509
1510 struct EnvGuard {
1511 key: &'static str,
1512 original: Option<OsString>,
1513 }
1514
1515 impl EnvGuard {
1516 fn set(key: &'static str, value: &str) -> Self {
1517 let original = std::env::var_os(key);
1518 unsafe {
1519 std::env::set_var(key, value);
1520 }
1521 Self { key, original }
1522 }
1523
1524 fn remove(key: &'static str) -> Self {
1525 let original = std::env::var_os(key);
1526 unsafe {
1527 std::env::remove_var(key);
1528 }
1529 Self { key, original }
1530 }
1531 }
1532
1533 impl Drop for EnvGuard {
1534 fn drop(&mut self) {
1535 match &self.original {
1536 Some(value) => unsafe { std::env::set_var(self.key, value) },
1537 None => unsafe { std::env::remove_var(self.key) },
1538 }
1539 }
1540 }
1541
1542 fn repo_market_catalog_path() -> &'static str {
1543 concat!(env!("CARGO_MANIFEST_DIR"), "/../../market_catalog.yaml")
1544 }
1545
1546 fn set_test_contract_env() -> (EnvGuard, EnvGuard, EnvGuard, EnvGuard, EnvGuard) {
1547 (
1548 EnvGuard::set(
1549 "EXCHANGE_CONTRACT_ADDRESS",
1550 TESTNET_EXCHANGE_CONTRACT_ADDRESS,
1551 ),
1552 EnvGuard::set(
1553 "CORE_DEPOSIT_WALLET_ADDRESS",
1554 TESTNET_CORE_DEPOSIT_WALLET_ADDRESS,
1555 ),
1556 EnvGuard::set("USDC_ADDRESS", TESTNET_USDC_ADDRESS),
1557 EnvGuard::set("OPTION_REGISTRY_ADDRESS", TESTNET_OPTION_REGISTRY_ADDRESS),
1558 EnvGuard::set(
1559 "OPTION_TOKEN_BEACON_PROXY_INIT_CODE_HASH",
1560 TESTNET_OPTION_TOKEN_BEACON_PROXY_INIT_CODE_HASH,
1561 ),
1562 )
1563 }
1564
1565 fn sample_yaml() -> String {
1566 let contracts_yaml = testnet_contracts_yaml_block();
1567 format!(
1568 r#"
1569environment:
1570 name: development
1571modes:
1572 testnet_mode: true
1573 enable_test_endpoints: true
1574 skip_external_oracle: true
1575{contracts_yaml}database:
1576 pool:
1577 diesel_max: 3
1578 journal_max: 2
1579api:
1580 max_open_orders_default: 100
1581 max_open_positions_default: 50
1582 global_trading_halted: false
1583 halted_markets: []
1584 ws_heartbeat_interval_ms: 20000
1585 ws_pong_timeout_ms: 60000
1586 markets_snapshot_refresh_ms: 1000
1587 require_risk_vol_readiness: false
1588pricing:
1589 hyperliquid_info_url: https://api.hyperliquid-testnet.xyz/info
1590 hyperliquid_ws_url: wss://api.hyperliquid-testnet.xyz/ws
1591 candle_ws_poll_interval_ms: 2000
1592 min_settlement_samples: 500
1593 hypercore_info_url: https://api.hyperliquid-testnet.xyz/info
1594 hypercore_update_interval_secs: 30
1595 oracle_symbol: BTC
1596hydromancer:
1597 enabled: false
1598 api_url: https://api.hydromancer.xyz/info
1599transaction_submitter:
1600 mode: mock
1601 max_gas_price: "500000000000"
1602 rpc_url: https://rpc.hyperliquid-testnet.xyz/evm
1603catalog_manager:
1604 enabled: false
1605 interval_secs: 3600
1606catalog:
1607 version: 1
1608 expiry:
1609 expiry_time_utc: "08:00"
1610 schedule:
1611 daily_count: 2
1612 weekly_count: 4
1613 monthly_count: 3
1614 underlyings:
1615 BTC:
1616 trading_mode: orderbook
1617 collateral:
1618 BTC_PERP:
1619 kind: perp
1620 asset_id: 3
1621 underlying: BTC
1622 USDC:
1623 kind: stablecoin
1624 token_id: 0
1625 strike_selection:
1626 deribit_table_assets: [BTC, ETH, SOL, AVAX, XRP, TRX]
1627 deribit_region_steps:
1628 atm: 3
1629 outer: 4
1630 wings: 3
1631 occ_fallback_side_count: 8
1632 extension_policy:
1633 enabled: false
1634 ensure_min_strikes_per_side: 4
1635 ensure_atm_within_pct: 0.01
1636 cooldown_secs: 60
1637 max_total_strikes_per_expiry: 20
1638 min_spot_move_pct: 0.01
1639 observability:
1640 log_level: info
1641 metrics_enabled: true
1642 vol_oracles:
1643 providers:
1644 fixed-btc:
1645 kind: fixed
1646 iv: 0.55
1647 routes:
1648 BTC: fixed-btc
1649engine:
1650 persistence:
1651 journal:
1652 event_persistence: full
1653 critical_event_types: [L2Update]
1654 wal_path: /tmp/hypercall-engine.wal
1655 batch_channel_capacity: 1024
1656 batch_size: 100
1657 flush_interval_ms: 10
1658 digests_enabled: true
1659 outbox:
1660 poll_ms: 10
1661 batch_size: 500
1662 max_drain_iterations: 0
1663 drain_timeout_secs: 30
1664 skip_drain: false
1665 snapshot_interval_secs: 60
1666 post_startup_reconcile_delay_secs: 5
1667 read_snapshot_interval_ms: 500
1668background_tasks:
1669 historical_pnl:
1670 max_periods: 100
1671 capture_every_5m_ms: 300000
1672 capture_every_1h_ms: 3600000
1673 capture_every_1d_ms: 86400000
1674 bbo_snapshot:
1675 interval_secs: 300
1676 retention_days: 7
1677observability:
1678 tracing:
1679 enabled: true
1680 otlp_endpoint: http://localhost:4317
1681 service_name: hypercall
1682 sample_ratio: 1.0
1683 pyroscope:
1684 enabled: false
1685 server_address:
1686 application_name: hypercall
1687 metrics:
1688 skip_db_queries: false
1689"#
1690 )
1691 }
1692
1693 #[test]
1694 fn parses_valid_backend_config() {
1695 let runtime = BackendRuntime::load_from_str(&sample_yaml()).unwrap();
1696 assert_eq!(runtime.config.pricing.oracle_symbol, "BTC");
1697 assert_eq!(
1698 runtime.config.contracts.exchange_contract_address,
1699 TESTNET_EXCHANGE_CONTRACT_ADDRESS
1700 );
1701 }
1702
1703 #[test]
1704 #[serial]
1705 fn portfolio_margin_pool_env_overrides_yaml_runtime() {
1706 let _mode_allowlist = EnvGuard::set(
1707 "PORTFOLIO_MARGIN_MODE_ALLOWLIST",
1708 "0x0000000000000000000000000000000000001681, 0x0000000000000000000000000000000000001682",
1709 );
1710 let _pool_enabled = EnvGuard::set("PORTFOLIO_MARGIN_POOL_ENABLED", "true");
1711 let _allowlist = EnvGuard::set(
1712 "PORTFOLIO_MARGIN_SETTLEMENT_ALLOWLIST",
1713 "0x0000000000000000000000000000000000001693, 0x0000000000000000000000000000000000001694",
1714 );
1715
1716 let mut runtime = BackendRuntime::load_from_str(&sample_yaml()).unwrap();
1717 assert!(!runtime.config.engine.portfolio_margin_pool_enabled);
1718 assert!(runtime
1719 .config
1720 .engine
1721 .portfolio_margin_mode_allowlist
1722 .is_empty());
1723 assert!(runtime
1724 .config
1725 .engine
1726 .portfolio_margin_settlement_allowlist
1727 .is_empty());
1728
1729 runtime.apply_portfolio_margin_pool_env_overrides().unwrap();
1730
1731 assert!(runtime.config.engine.portfolio_margin_pool_enabled);
1732 assert_eq!(
1733 runtime.config.engine.portfolio_margin_mode_allowlist,
1734 vec![
1735 "0x0000000000000000000000000000000000001681",
1736 "0x0000000000000000000000000000000000001682"
1737 ]
1738 );
1739 assert_eq!(
1740 runtime.config.engine.portfolio_margin_settlement_allowlist,
1741 vec![
1742 "0x0000000000000000000000000000000000001693",
1743 "0x0000000000000000000000000000000000001694"
1744 ]
1745 );
1746 }
1747
1748 #[test]
1749 fn committed_staging_backend_config_parses() {
1750 BackendRuntime::load_from_str_for_config_only(include_str!(concat!(
1751 env!("CARGO_MANIFEST_DIR"),
1752 "/../../config/backend.staging.yaml"
1753 )))
1754 .expect("committed staging backend config should parse");
1755 }
1756
1757 #[test]
1758 fn committed_aws_staging_backend_config_parses() {
1759 BackendRuntime::load_from_str_for_config_only(include_str!(concat!(
1760 env!("CARGO_MANIFEST_DIR"),
1761 "/../../infrastructure/aws/nonprod/staging/k8s/hypercall-staging/config/backend.staging.yaml"
1762 )))
1763 .expect("committed AWS staging backend config should parse");
1764 }
1765
1766 #[test]
1767 fn committed_linode_staging_backend_config_parses() {
1768 BackendRuntime::load_from_str_for_config_only(include_str!(concat!(
1769 env!("CARGO_MANIFEST_DIR"),
1770 "/../../infrastructure/linode/staging/k8s/hypercall-staging/config/backend.staging.yaml"
1771 )))
1772 .expect("committed Linode staging backend config should parse");
1773 }
1774
1775 #[test]
1776 fn committed_aws_production_backend_config_parses() {
1777 let runtime = BackendRuntime::load_from_str_for_config_only(include_str!(concat!(
1778 env!("CARGO_MANIFEST_DIR"),
1779 "/../../infrastructure/aws/prod/production/k8s/hypercall-production/config/backend.production.yaml"
1780 )))
1781 .expect("committed AWS production backend config should parse");
1782 assert!(
1783 !runtime.config.api.global_trading_halted,
1784 "AWS production backend config must not globally halt trading"
1785 );
1786 }
1787
1788 #[test]
1789 fn committed_testnet_backend_config_parses() {
1790 BackendRuntime::load_from_str_for_config_only(include_str!(concat!(
1791 env!("CARGO_MANIFEST_DIR"),
1792 "/../../config/backend.testnet.yaml"
1793 )))
1794 .expect("committed testnet backend config should parse");
1795 }
1796
1797 #[test]
1798 fn rejects_unknown_field() {
1799 assert!(BackendRuntime::load_from_str(
1800 &sample_yaml().replace("modes:\n", "modes:\n nope: true\n"),
1801 )
1802 .is_err());
1803 }
1804
1805 #[test]
1806 fn aws_kms_rsm_signer_uses_iam_credentials() {
1807 let mut config = BackendConfig::default();
1808 config.rsm_signer.provider = RsmSignerProvider::AwsKms;
1809 config.rsm_signer.aws_kms_key_id = "alias/hypercall-staging-rsm-signer".to_string();
1810
1811 BackendSecrets::default()
1812 .validate(&config)
1813 .expect("aws_kms RSM signer should use IAM credentials");
1814 }
1815
1816 #[test]
1817 fn aws_kms_rsm_signer_requires_key_id() {
1818 let mut config = BackendConfig::default();
1819 config.rsm_signer.provider = RsmSignerProvider::AwsKms;
1820
1821 let err = BackendSecrets::default()
1822 .validate(&config)
1823 .unwrap_err()
1824 .to_string();
1825
1826 assert!(err.contains("rsm_signer.provider=aws_kms requires rsm_signer.aws_kms_key_id"));
1827 }
1828
1829 #[test]
1830 fn aws_kms_rsm_signer_requires_key_id_even_with_direct_submitter() {
1831 let mut config = BackendConfig::default();
1832 config.transaction_submitter.mode = TransactionSubmitterMode::Direct;
1833 config.rsm_signer.provider = RsmSignerProvider::AwsKms;
1834
1835 let secrets = BackendSecrets {
1836 transaction_submitter_private_key: Some(
1837 "0x0123456789012345678901234567890123456789012345678901234567890123".to_string(),
1838 ),
1839 ..BackendSecrets::default()
1840 };
1841
1842 let err = secrets.validate(&config).unwrap_err().to_string();
1843 assert!(err.contains("rsm_signer.provider=aws_kms requires rsm_signer.aws_kms_key_id"));
1844 }
1845
1846 #[test]
1847 fn aws_kms_transaction_submitter_does_not_require_plaintext_key() {
1848 let mut config = BackendConfig::default();
1849 config.transaction_submitter.mode = TransactionSubmitterMode::AwsKms;
1850 config.transaction_submitter.aws_kms_key_id =
1851 "alias/hypercall-staging-tx-submitter".to_string();
1852
1853 BackendSecrets::default()
1854 .validate(&config)
1855 .expect("aws_kms transaction submitter should use IAM instead of plaintext keys");
1856 }
1857
1858 #[test]
1859 fn aws_kms_transaction_submitter_requires_key_id() {
1860 let mut config = BackendConfig::default();
1861 config.transaction_submitter.mode = TransactionSubmitterMode::AwsKms;
1862
1863 let err = BackendSecrets::default()
1864 .validate(&config)
1865 .unwrap_err()
1866 .to_string();
1867
1868 assert!(err.contains(
1869 "transaction_submitter.mode=aws_kms requires transaction_submitter.aws_kms_key_id"
1870 ));
1871 }
1872
1873 #[test]
1874 fn aws_kms_transaction_submitter_does_not_satisfy_liquidation_rsm_signer() {
1875 let mut config = BackendConfig::default();
1876 config.contracts.exchange_contract_address = TESTNET_EXCHANGE_CONTRACT_ADDRESS.to_string();
1877 config.contracts.core_deposit_wallet_address =
1878 TESTNET_CORE_DEPOSIT_WALLET_ADDRESS.to_string();
1879 config.contracts.usdc_address = TESTNET_USDC_ADDRESS.to_string();
1880 config.contracts.option_registry_address = TESTNET_OPTION_REGISTRY_ADDRESS.to_string();
1881 config.contracts.option_token_beacon_proxy_init_code_hash =
1882 TESTNET_OPTION_TOKEN_BEACON_PROXY_INIT_CODE_HASH.to_string();
1883 config.transaction_submitter.mode = TransactionSubmitterMode::AwsKms;
1884 config.transaction_submitter.aws_kms_key_id =
1885 "alias/hypercall-staging-tx-submitter".to_string();
1886 config.liquidation.enabled = true;
1887
1888 let err = config.validate().unwrap_err().to_string();
1889 assert!(
1890 err.contains(
1891 "liquidation.enabled requires rsm_signer.aws_kms_key_id or transaction_submitter.mode=direct"
1892 ),
1893 "{err}"
1894 );
1895 }
1896
1897 #[test]
1898 fn rejects_invalid_ws_timeouts() {
1899 let err = BackendRuntime::load_from_str(
1900 &sample_yaml().replace(" ws_pong_timeout_ms: 60000", " ws_pong_timeout_ms: 1000"),
1901 )
1902 .unwrap_err()
1903 .to_string();
1904 assert!(err.contains("ws_pong_timeout_ms"));
1905 }
1906
1907 #[test]
1908 fn rejects_zero_sized_db_pools() {
1909 let err = BackendRuntime::load_from_str(
1910 &sample_yaml().replace(" diesel_max: 3", " diesel_max: 0"),
1911 )
1912 .unwrap_err()
1913 .to_string();
1914 assert!(err.contains("database.pool.diesel_max"));
1915 }
1916
1917 #[test]
1918 fn rejects_missing_contracts_config() {
1919 let err = BackendRuntime::load_from_str(
1920 &sample_yaml().replace(&testnet_contracts_yaml_block(), ""),
1921 )
1922 .unwrap_err()
1923 .to_string();
1924
1925 assert!(err.contains("contracts.exchange_contract_address"));
1926 }
1927
1928 #[test]
1929 #[serial]
1930 fn non_full_runtime_skips_contract_validation() {
1931 let _database_url = EnvGuard::set(
1932 "DATABASE_URL",
1933 "postgresql://test_user:test_password@localhost:5432/hypercall_test",
1934 );
1935 let yaml_without_contracts = sample_yaml().replace(&testnet_contracts_yaml_block(), "");
1936
1937 BackendRuntime::load_from_str_for_migrations(&yaml_without_contracts)
1938 .expect("migration runtime should not require contracts config");
1939 BackendRuntime::load_from_str_for_config_only(&yaml_without_contracts)
1940 .expect("config-only runtime should not require contracts config");
1941 BackendRuntime::load_from_str_for_database_only(&yaml_without_contracts)
1942 .expect("database-only runtime should not require contracts config");
1943 BackendRuntime::load_from_str_for_database_only(&yaml_without_contracts)
1944 .expect("database-only runtime should not require contracts config (2)");
1945 }
1946
1947 #[test]
1948 #[serial]
1949 fn resolves_catalog_secret_placeholders() {
1950 let _guard = EnvGuard::set("BACKEND_CONFIG_TEST_POLYGON_KEY", "polygon-secret");
1951 let yaml = sample_yaml().replace(
1952 r#" providers:
1953 fixed-btc:
1954 kind: fixed
1955 iv: 0.55
1956 routes:
1957 BTC: fixed-btc"#,
1958 r#" providers:
1959 polygon-btc:
1960 kind: polygon
1961 api_key: ${BACKEND_CONFIG_TEST_POLYGON_KEY}
1962 underlyings:
1963 BTC:
1964 ticker: X:BTCUSD
1965 strike_scale: 1.0
1966 routes:
1967 BTC: polygon-btc"#,
1968 );
1969
1970 let runtime = BackendRuntime::load_from_str(&yaml).unwrap();
1971 let provider = runtime
1972 .config
1973 .catalog
1974 .vol_oracles
1975 .providers
1976 .get("polygon-btc")
1977 .unwrap();
1978
1979 match provider {
1980 catalog_manager::VolOracleProviderConfig::Polygon(config) => {
1981 assert_eq!(config.api_key, "polygon-secret");
1982 }
1983 _ => panic!("expected polygon provider"),
1984 }
1985 }
1986
1987 #[test]
1988 #[serial]
1989 fn fails_when_enabled_secret_is_missing() {
1990 let _guard = EnvGuard::remove("HYDROMANCER_API_KEY");
1991 let err = BackendRuntime::load_from_str(
1992 &sample_yaml().replace(" enabled: false", " enabled: true"),
1993 )
1994 .unwrap_err()
1995 .to_string();
1996 assert!(err.contains("HYDROMANCER_API_KEY"));
1997 }
1998
1999 #[test]
2000 #[serial]
2001 fn migration_runtime_only_requires_database_secret() {
2002 let _database_url = EnvGuard::set(
2003 "DATABASE_URL",
2004 "postgresql://test_user:test_password@localhost:5432/hypercall_test",
2005 );
2006 let _hydromancer = EnvGuard::remove("HYDROMANCER_API_KEY");
2007
2008 let yaml = sample_yaml()
2009 .replace(
2010 "hydromancer:\n enabled: false",
2011 "hydromancer:\n enabled: true",
2012 )
2013 .replace(
2014 "transaction_submitter:\n mode: mock",
2015 "transaction_submitter:\n mode: direct",
2016 );
2017
2018 let runtime = BackendRuntime::load_from_str_for_migrations(&yaml).unwrap();
2019 assert!(runtime.config.hydromancer.enabled);
2020 assert_eq!(
2021 runtime.config.transaction_submitter.mode,
2022 TransactionSubmitterMode::Direct
2023 );
2024 }
2025
2026 #[test]
2027 #[serial]
2028 fn migration_runtime_allows_unresolved_catalog_secret_placeholders() {
2029 let _database_url = EnvGuard::set(
2030 "DATABASE_URL",
2031 "postgresql://test_user:test_password@localhost:5432/hypercall_test",
2032 );
2033 let _polygon_key = EnvGuard::remove("BACKEND_CONFIG_TEST_POLYGON_KEY");
2034 let yaml = sample_yaml().replace(
2035 r#" providers:
2036 fixed-btc:
2037 kind: fixed
2038 iv: 0.55
2039 routes:
2040 BTC: fixed-btc"#,
2041 r#" providers:
2042 polygon-btc:
2043 kind: polygon
2044 api_key: ${BACKEND_CONFIG_TEST_POLYGON_KEY}
2045 underlyings:
2046 BTC:
2047 ticker: X:BTCUSD
2048 strike_scale: 1.0
2049 routes:
2050 BTC: polygon-btc"#,
2051 );
2052
2053 let runtime = BackendRuntime::load_from_str_for_migrations(&yaml).unwrap();
2054 let provider = runtime
2055 .config
2056 .catalog
2057 .vol_oracles
2058 .providers
2059 .get("polygon-btc")
2060 .unwrap();
2061
2062 match provider {
2063 catalog_manager::VolOracleProviderConfig::Polygon(config) => {
2064 assert_eq!(config.api_key, "${BACKEND_CONFIG_TEST_POLYGON_KEY}");
2065 }
2066 _ => panic!("expected polygon provider"),
2067 }
2068 }
2069
2070 #[test]
2071 #[serial]
2072 fn non_full_runtime_modes_allow_unresolved_catalog_secret_placeholders() {
2073 let _database_url = EnvGuard::set(
2074 "DATABASE_URL",
2075 "postgresql://test_user:test_password@localhost:5432/hypercall_test",
2076 );
2077 let _polygon_key = EnvGuard::remove("BACKEND_CONFIG_TEST_POLYGON_KEY");
2078 let yaml = sample_yaml().replace(
2079 r#" providers:
2080 fixed-btc:
2081 kind: fixed
2082 iv: 0.55
2083 routes:
2084 BTC: fixed-btc"#,
2085 r#" providers:
2086 polygon-btc:
2087 kind: polygon
2088 api_key: ${BACKEND_CONFIG_TEST_POLYGON_KEY}
2089 underlyings:
2090 BTC:
2091 ticker: X:BTCUSD
2092 strike_scale: 1.0
2093 routes:
2094 BTC: polygon-btc"#,
2095 );
2096
2097 for loader in [
2098 BackendRuntime::load_from_str_for_config_only,
2099 BackendRuntime::load_from_str_for_database_only,
2100 ] {
2101 let runtime = loader(&yaml).expect("non-full runtime should allow placeholders");
2102 let provider = runtime
2103 .config
2104 .catalog
2105 .vol_oracles
2106 .providers
2107 .get("polygon-btc")
2108 .expect("polygon provider should be present");
2109
2110 match provider {
2111 catalog_manager::VolOracleProviderConfig::Polygon(config) => {
2112 assert_eq!(config.api_key, "${BACKEND_CONFIG_TEST_POLYGON_KEY}");
2113 }
2114 _ => panic!("expected polygon provider"),
2115 }
2116 }
2117 }
2118
2119 #[test]
2120 #[serial]
2121 fn full_runtime_requires_catalog_secret_placeholders_to_resolve() {
2122 let _polygon_key = EnvGuard::remove("BACKEND_CONFIG_TEST_POLYGON_KEY");
2123 let yaml = sample_yaml().replace(
2124 r#" providers:
2125 fixed-btc:
2126 kind: fixed
2127 iv: 0.55
2128 routes:
2129 BTC: fixed-btc"#,
2130 r#" providers:
2131 polygon-btc:
2132 kind: polygon
2133 api_key: ${BACKEND_CONFIG_TEST_POLYGON_KEY}
2134 underlyings:
2135 BTC:
2136 ticker: X:BTCUSD
2137 strike_scale: 1.0
2138 routes:
2139 BTC: polygon-btc"#,
2140 );
2141
2142 let err = BackendRuntime::load_from_str(&yaml).unwrap_err();
2143 let rendered = format!("{err:#}");
2144 assert!(rendered.contains("BACKEND_CONFIG_TEST_POLYGON_KEY"));
2145 }
2146
2147 #[test]
2148 #[serial]
2149 fn migration_runtime_requires_database_secret() {
2150 let _database_url = EnvGuard::remove("DATABASE_URL");
2151 let _direct_url = EnvGuard::remove("DIRECT_URL");
2152
2153 let err = BackendRuntime::load_from_str_for_migrations(&sample_yaml())
2154 .unwrap_err()
2155 .to_string();
2156 assert!(
2157 err.contains("DIRECT_URL or DATABASE_URL"),
2158 "unexpected error: {}",
2159 err
2160 );
2161 }
2162
2163 #[test]
2164 #[serial]
2165 fn migration_runtime_skips_legacy_catalog_loading() {
2166 let _database_url = EnvGuard::set(
2167 "DATABASE_URL",
2168 "postgresql://test_user:test_password@localhost:5432/hypercall_test",
2169 );
2170 let _catalog_path = EnvGuard::set(
2171 "CATALOG_MANAGER_CATALOG_PATH",
2172 "/tmp/definitely-missing-catalog-for-migrations.yaml",
2173 );
2174
2175 BackendRuntime::from_legacy_env_for_migrations()
2176 .expect("migration runtime should not require legacy catalog assets");
2177 }
2178
2179 #[test]
2180 #[serial]
2181 fn non_full_legacy_env_runtime_skips_legacy_catalog_loading() {
2182 let _database_url = EnvGuard::set(
2183 "DATABASE_URL",
2184 "postgresql://test_user:test_password@localhost:5432/hypercall_test",
2185 );
2186 let _catalog_path = EnvGuard::set(
2187 "CATALOG_MANAGER_CATALOG_PATH",
2188 "/tmp/definitely-missing-catalog-for-non-full-runtime.yaml",
2189 );
2190
2191 BackendRuntime::from_legacy_env_for_config_only()
2192 .expect("config-only runtime should not require legacy catalog assets");
2193 BackendRuntime::from_legacy_env_for_database_only()
2194 .expect("database-only runtime should not require legacy catalog assets");
2195 }
2196
2197 #[test]
2198 #[serial]
2199 fn config_only_runtime_skips_unrelated_secret_validation() {
2200 let _database_url = EnvGuard::remove("DATABASE_URL");
2201 let _hydromancer = EnvGuard::remove("HYDROMANCER_API_KEY");
2202
2203 let yaml = sample_yaml()
2204 .replace(
2205 "hydromancer:\n enabled: false",
2206 "hydromancer:\n enabled: true",
2207 )
2208 .replace(
2209 "transaction_submitter:\n mode: mock",
2210 "transaction_submitter:\n mode: direct",
2211 );
2212
2213 let runtime = BackendRuntime::load_from_str_for_config_only(&yaml).unwrap();
2214 assert!(runtime.config.hydromancer.enabled);
2215 assert_eq!(
2216 runtime.config.transaction_submitter.mode,
2217 TransactionSubmitterMode::Direct
2218 );
2219 }
2220
2221 #[test]
2222 #[serial]
2223 fn database_only_runtime_requires_database_but_skips_other_secrets() {
2224 let _database_url = EnvGuard::set(
2225 "DATABASE_URL",
2226 "postgresql://test_user:test_password@localhost:5432/hypercall_test",
2227 );
2228 let _hydromancer = EnvGuard::remove("HYDROMANCER_API_KEY");
2229
2230 let yaml = sample_yaml()
2231 .replace(
2232 "hydromancer:\n enabled: false",
2233 "hydromancer:\n enabled: true",
2234 )
2235 .replace(
2236 "transaction_submitter:\n mode: mock",
2237 "transaction_submitter:\n mode: direct",
2238 );
2239
2240 let runtime = BackendRuntime::load_from_str_for_database_only(&yaml).unwrap();
2241 assert!(runtime.config.hydromancer.enabled);
2242 assert_eq!(
2243 runtime.config.transaction_submitter.mode,
2244 TransactionSubmitterMode::Direct
2245 );
2246 }
2247
2248 #[test]
2249 #[serial]
2250 fn legacy_env_runtime_uses_catalog_manager_catalog_path() {
2251 let _contracts = set_test_contract_env();
2252 let dir = tempfile::tempdir().expect("create tempdir");
2253 let catalog_path = dir.path().join("catalog.yaml");
2254 std::fs::write(
2255 &catalog_path,
2256 r#"
2257version: 1
2258expiry:
2259 expiry_time_utc: "08:00"
2260 schedule:
2261 daily_count: 1
2262 weekly_count: 1
2263 monthly_count: 1
2264underlyings:
2265 ETH:
2266 trading_mode: orderbook
2267collateral:
2268 ETH_PERP:
2269 kind: perp
2270 asset_id: 4
2271 underlying: ETH
2272 USDC:
2273 kind: stablecoin
2274 token_id: 0
2275strike_selection:
2276 deribit_table_assets: [BTC, ETH, SOL, AVAX, XRP, TRX]
2277 deribit_region_steps:
2278 atm: 3
2279 outer: 4
2280 wings: 3
2281 occ_fallback_side_count: 8
2282extension_policy:
2283 enabled: false
2284 ensure_min_strikes_per_side: 2
2285 ensure_atm_within_pct: 0.01
2286 cooldown_secs: 60
2287 max_total_strikes_per_expiry: 10
2288 min_spot_move_pct: 0.01
2289observability:
2290 log_level: info
2291 metrics_enabled: true
2292vol_oracles:
2293 providers:
2294 fixed-eth:
2295 kind: fixed
2296 iv: 0.4
2297 routes:
2298 ETH: fixed-eth
2299"#,
2300 )
2301 .expect("write catalog");
2302 let _catalog_path = EnvGuard::set(
2303 "CATALOG_MANAGER_CATALOG_PATH",
2304 catalog_path.to_str().expect("catalog path string"),
2305 );
2306
2307 let runtime = BackendRuntime::from_legacy_env().unwrap();
2308 assert!(runtime.config.catalog.underlyings.contains_key("ETH"));
2309 assert!(!runtime.config.catalog.underlyings.contains_key("BTC"));
2310 }
2311
2312 #[test]
2313 #[serial]
2314 fn legacy_env_runtime_applies_runtime_overrides() {
2315 let _contracts = set_test_contract_env();
2316 let _testnet = EnvGuard::set("TESTNET_MODE", "1");
2317 let _post_startup = EnvGuard::set("ENGINE_POST_STARTUP_RECONCILE_DELAY_SECS", "0");
2318 let _orders = EnvGuard::set("MAX_OPEN_ORDERS_DEFAULT", "42");
2319 let _catalog_path =
2320 EnvGuard::set("CATALOG_MANAGER_CATALOG_PATH", repo_market_catalog_path());
2321
2322 let runtime = BackendRuntime::from_legacy_env().unwrap();
2323 assert!(runtime.config.modes.testnet_mode);
2324 assert_eq!(runtime.config.engine.post_startup_reconcile_delay_secs, 0);
2325 assert_eq!(runtime.config.api.max_open_orders_default, 42);
2326 assert_eq!(
2327 runtime.config.contracts.option_registry_address,
2328 TESTNET_OPTION_REGISTRY_ADDRESS
2329 );
2330 }
2331
2332 #[test]
2333 #[serial]
2334 fn legacy_env_runtime_uses_testnet_pricing_defaults() {
2335 let _contracts = set_test_contract_env();
2336 let _testnet = EnvGuard::set("TESTNET_MODE", "1");
2337 let _catalog_path =
2338 EnvGuard::set("CATALOG_MANAGER_CATALOG_PATH", repo_market_catalog_path());
2339 let _info_url = EnvGuard::remove("HYPERLIQUID_INFO_URL");
2340 let _ws_url = EnvGuard::remove("HYPERLIQUID_WS_URL");
2341 let _hypercore_info_url = EnvGuard::remove("HYPERCORE_INFO_URL");
2342
2343 let runtime = BackendRuntime::from_legacy_env().unwrap();
2344 assert_eq!(
2345 runtime.config.pricing.hyperliquid_info_url,
2346 DEFAULT_HYPERLIQUID_TESTNET_INFO_URL
2347 );
2348 assert_eq!(
2349 runtime.config.pricing.hyperliquid_ws_url,
2350 crate::price_oracle::hyperliquid_ws::DEFAULT_TESTNET_WS_URL
2351 );
2352 assert_eq!(
2353 runtime.config.pricing.hypercore_info_url,
2354 DEFAULT_HYPERLIQUID_TESTNET_INFO_URL
2355 );
2356 }
2357
2358 #[test]
2359 #[serial]
2360 fn legacy_env_runtime_promotes_transaction_submitter_to_direct() {
2361 let _contracts = set_test_contract_env();
2362 let _catalog_path =
2363 EnvGuard::set("CATALOG_MANAGER_CATALOG_PATH", repo_market_catalog_path());
2364 let _mode = EnvGuard::remove("TRANSACTION_SUBMITTER_MODE");
2365 let _signer = EnvGuard::set(
2366 "TRANSACTION_SUBMITTER_PRIVATE_KEY",
2367 "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80",
2368 );
2369 let _rpc_url = EnvGuard::set("RPC_URL", "https://rpc.hyperliquid-testnet.xyz/evm");
2370 let _max_gas_price = EnvGuard::set("MAX_GAS_PRICE", "500000000000");
2371 let _exchange_address = EnvGuard::set(
2372 "EXCHANGE_CONTRACT_ADDRESS",
2373 "0x1d70Ff185F6C25E4d76163F16563D63d5b590Cbc",
2374 );
2375
2376 let runtime = BackendRuntime::from_legacy_env().unwrap();
2377 assert_eq!(
2378 runtime.config.transaction_submitter.mode,
2379 TransactionSubmitterMode::Direct
2380 );
2381 }
2382
2383 #[test]
2384 #[serial]
2385 fn legacy_env_runtime_promotes_transaction_submitter_to_aws_kms() {
2386 let _contracts = set_test_contract_env();
2387 let _catalog_path =
2388 EnvGuard::set("CATALOG_MANAGER_CATALOG_PATH", repo_market_catalog_path());
2389 let _mode = EnvGuard::remove("TRANSACTION_SUBMITTER_MODE");
2390 let _plaintext_signer = EnvGuard::remove("TRANSACTION_SUBMITTER_PRIVATE_KEY");
2391 let _kms_key = EnvGuard::set(
2392 "TRANSACTION_SUBMITTER_AWS_KMS_KEY_ID",
2393 "alias/hypercall-staging-tx-submitter",
2394 );
2395 let _rpc_url = EnvGuard::set("RPC_URL", "https://rpc.hyperliquid.xyz/evm");
2396 let _max_gas_price = EnvGuard::set("MAX_GAS_PRICE", "1000000000");
2397
2398 let runtime = BackendRuntime::from_legacy_env().unwrap();
2399 assert_eq!(
2400 runtime.config.transaction_submitter.mode,
2401 TransactionSubmitterMode::AwsKms
2402 );
2403 assert_eq!(
2404 runtime.config.transaction_submitter.aws_kms_key_id,
2405 "alias/hypercall-staging-tx-submitter"
2406 );
2407 }
2408
2409 #[test]
2410 #[serial]
2411 fn explicit_transaction_submitter_mode_env_accepts_aws_kms() {
2412 let _contracts = set_test_contract_env();
2413 let _catalog_path =
2414 EnvGuard::set("CATALOG_MANAGER_CATALOG_PATH", repo_market_catalog_path());
2415 let _mode = EnvGuard::set("TRANSACTION_SUBMITTER_MODE", "aws_kms");
2416 let _kms_key = EnvGuard::set(
2417 "TRANSACTION_SUBMITTER_AWS_KMS_KEY_ID",
2418 "alias/hypercall-staging-tx-submitter",
2419 );
2420
2421 let runtime = BackendRuntime::from_legacy_env().unwrap();
2422 assert_eq!(
2423 runtime.config.transaction_submitter.mode,
2424 TransactionSubmitterMode::AwsKms
2425 );
2426 }
2427
2428 #[test]
2429 #[serial]
2430 fn legacy_env_runtime_allows_direct_liquidation_with_local_signer() {
2431 let _contracts = set_test_contract_env();
2432 let _catalog_path =
2433 EnvGuard::set("CATALOG_MANAGER_CATALOG_PATH", repo_market_catalog_path());
2434 let _signer = EnvGuard::set(
2435 "TRANSACTION_SUBMITTER_PRIVATE_KEY",
2436 "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80",
2437 );
2438 let _rpc_url = EnvGuard::set("RPC_URL", "https://rpc.hyperliquid-testnet.xyz/evm");
2439 let _max_gas_price = EnvGuard::set("MAX_GAS_PRICE", "500000000000");
2440 let _exchange_address = EnvGuard::set(
2441 "EXCHANGE_CONTRACT_ADDRESS",
2442 "0x1d70Ff185F6C25E4d76163F16563D63d5b590Cbc",
2443 );
2444 let _enabled = EnvGuard::set("LIQUIDATION_ENABLED", "true");
2445
2446 let runtime = BackendRuntime::from_legacy_env().unwrap();
2447 assert_eq!(
2448 runtime.config.transaction_submitter.mode,
2449 TransactionSubmitterMode::Direct
2450 );
2451 assert!(runtime.config.liquidation.enabled);
2452 assert_eq!(runtime.config.rsm_signer.provider, RsmSignerProvider::Local);
2453 }
2454
2455 #[test]
2456 #[serial]
2457 fn legacy_env_runtime_promotes_rsm_signer_to_aws_kms_from_key_id() {
2458 let _contracts = set_test_contract_env();
2459 let _catalog_path =
2460 EnvGuard::set("CATALOG_MANAGER_CATALOG_PATH", repo_market_catalog_path());
2461 let _rsm_provider = EnvGuard::remove("RSM_SIGNER_PROVIDER");
2462 let _rsm_kms_key = EnvGuard::set(
2463 "RSM_SIGNER_AWS_KMS_KEY_ID",
2464 "alias/hypercall-staging-rsm-signer",
2465 );
2466
2467 let runtime = BackendRuntime::from_legacy_env().unwrap();
2468
2469 assert_eq!(
2470 runtime.config.rsm_signer.provider,
2471 RsmSignerProvider::AwsKms
2472 );
2473 assert_eq!(
2474 runtime.config.rsm_signer.aws_kms_key_id,
2475 "alias/hypercall-staging-rsm-signer"
2476 );
2477 }
2478
2479 #[test]
2480 #[serial]
2481 fn legacy_env_runtime_accepts_explicit_rsm_signer_provider() {
2482 let _contracts = set_test_contract_env();
2483 let _catalog_path =
2484 EnvGuard::set("CATALOG_MANAGER_CATALOG_PATH", repo_market_catalog_path());
2485 let _rsm_provider = EnvGuard::set("RSM_SIGNER_PROVIDER", "aws_kms");
2486 let _rsm_kms_key = EnvGuard::set(
2487 "RSM_SIGNER_AWS_KMS_KEY_ID",
2488 "alias/hypercall-staging-rsm-signer",
2489 );
2490
2491 let runtime = BackendRuntime::from_legacy_env().unwrap();
2492
2493 assert_eq!(
2494 runtime.config.rsm_signer.provider,
2495 RsmSignerProvider::AwsKms
2496 );
2497 }
2498
2499 #[test]
2500 #[serial]
2501 fn legacy_env_runtime_does_not_promote_to_direct_without_signer() {
2502 let _contracts = set_test_contract_env();
2503 let _catalog_path =
2504 EnvGuard::set("CATALOG_MANAGER_CATALOG_PATH", repo_market_catalog_path());
2505 let _signer = EnvGuard::remove("TRANSACTION_SUBMITTER_PRIVATE_KEY");
2506 let _rpc_url = EnvGuard::set("RPC_URL", "https://rpc.hyperliquid-testnet.xyz/evm");
2507 let _max_gas_price = EnvGuard::set("MAX_GAS_PRICE", "500000000000");
2508
2509 let runtime = BackendRuntime::from_legacy_env().unwrap();
2510 assert_eq!(
2511 runtime.config.transaction_submitter.mode,
2512 TransactionSubmitterMode::Mock
2513 );
2514 }
2515
2516 #[test]
2517 #[serial]
2518 fn legacy_env_runtime_applies_liquidation_overrides() {
2519 let _contracts = set_test_contract_env();
2520 let _catalog_path =
2521 EnvGuard::set("CATALOG_MANAGER_CATALOG_PATH", repo_market_catalog_path());
2522 let _signer = EnvGuard::set(
2523 "TRANSACTION_SUBMITTER_PRIVATE_KEY",
2524 "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80",
2525 );
2526 let _rpc_url = EnvGuard::set("RPC_URL", "https://rpc.hyperliquid-testnet.xyz/evm");
2527 let _max_gas_price = EnvGuard::set("MAX_GAS_PRICE", "500000000000");
2528 let _enabled = EnvGuard::set("LIQUIDATION_ENABLED", "true");
2529 let _poll = EnvGuard::set("LIQUIDATION_HEALTH_POLL_INTERVAL_MS", "123");
2530 let _buffer = EnvGuard::set("LIQUIDATION_PARTIAL_TARGET_BUFFER_BPS", "456");
2531 let _threshold = EnvGuard::set("LIQUIDATION_MIN_SHORTFALL_THRESHOLD", "7.5");
2532
2533 let runtime = BackendRuntime::from_legacy_env().unwrap();
2534 assert!(runtime.config.liquidation.enabled);
2535 assert_eq!(runtime.config.liquidation.health_poll_interval_ms, 123);
2536 assert_eq!(runtime.config.liquidation.partial_target_buffer_bps, 456);
2537 assert_eq!(
2538 runtime.config.liquidation.min_shortfall_threshold,
2539 Decimal::from_str("7.5").unwrap()
2540 );
2541 }
2542
2543 #[test]
2544 #[serial]
2545 fn legacy_env_runtime_requires_contract_env() {
2546 let _exchange_address = EnvGuard::remove("EXCHANGE_CONTRACT_ADDRESS");
2547 let _option_registry = EnvGuard::remove("OPTION_REGISTRY_ADDRESS");
2548 let _init_code_hash = EnvGuard::remove("OPTION_TOKEN_BEACON_PROXY_INIT_CODE_HASH");
2549 let _catalog_path =
2550 EnvGuard::set("CATALOG_MANAGER_CATALOG_PATH", repo_market_catalog_path());
2551
2552 let err = BackendRuntime::from_legacy_env().unwrap_err().to_string();
2553 assert!(err.contains("contracts.exchange_contract_address"));
2554 }
2555}