Skip to main content

hypercall/
backend_config.rs

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            // Separate WS key for Hydromancer fill/position feeds.
385            // The main HYDROMANCER_API_KEY is for mainnet oracle price feeds and must stay mainnet.
386            // Set HYDROMANCER_WS_API_KEY for testnet fill/position WS feeds.
387            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                // Monitoring endpoints expose full account, position, and engine
413                // state, so every deployed environment must have an admin key.
414                // Only local development may run without one.
415                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    /// REST endpoint for pricing oracle (mainnet in staging).
665    pub api_url: String,
666    /// WebSocket endpoint for fill/position feeds (testnet in staging).
667    pub ws_url: Option<String>,
668    /// API key for WS feed. Falls back to HYDROMANCER_WS_API_KEY / HYDROMANCER_API_KEY env.
669    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    /// Hydromancer WebSocket URL for live ledger event streaming.
770    /// When set, the cash ledger observer uses WS instead of HTTP polling.
771    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}