1use std::path::Path;
18
19use degenbot_config::readiness::StrategyArm;
20use degenbot_config::writer::{write_key_with_env, WriteOutcome};
21use degenbot_config::{strategy_readiness, SCHEMA};
22
23use crate::context::CliContext;
24use crate::error::CliError;
25use crate::prompt::PromptPlan;
26use crate::report::StrategyReport;
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum StrategyFacet {
32 Settlement,
34 MevblockerBackrun,
36 TxpoolBackrun,
38}
39
40impl StrategyFacet {
41 pub const ALL: [Self; 3] = [
43 Self::Settlement,
44 Self::MevblockerBackrun,
45 Self::TxpoolBackrun,
46 ];
47
48 #[must_use]
50 pub const fn as_str(self) -> &'static str {
51 match self {
52 Self::Settlement => "settlement",
53 Self::MevblockerBackrun => "mevblocker_backrun",
54 Self::TxpoolBackrun => "txpool_backrun",
55 }
56 }
57
58 #[must_use]
60 pub const fn trigger_kind(self) -> &'static str {
61 match self {
62 Self::Settlement => "settled-block",
63 Self::MevblockerBackrun | Self::TxpoolBackrun => "pending-transaction",
64 }
65 }
66
67 #[must_use]
69 pub const fn config_section(self) -> &'static str {
70 match self {
71 Self::Settlement => "strategy.settlement",
72 Self::MevblockerBackrun => "strategy.mevblocker_backrun",
73 Self::TxpoolBackrun => "strategy.txpool_backrun",
74 }
75 }
76
77 #[must_use]
79 pub fn activation_key(self) -> &'static degenbot_config::KeyDecl {
80 facet_key(self.config_section(), "active")
81 }
82
83 #[must_use]
85 pub fn endpoints_key(self) -> &'static degenbot_config::KeyDecl {
86 facet_key(self.config_section(), "endpoints")
87 }
88
89 #[must_use]
92 pub fn default_endpoint_set(self) -> Vec<String> {
93 let urls: &[&str] = match self {
94 Self::Settlement => degenbot_config::SETTLEMENT_DEFAULT_ENDPOINTS,
95 Self::MevblockerBackrun => {
96 std::slice::from_ref(°enbot_config::DEFAULT_BACKRUN_STREAM_URL)
97 }
98 Self::TxpoolBackrun => degenbot_config::DEFAULT_TXPOOL_BACKRUN_RELAYS,
99 };
100 urls.iter().map(|url| (*url).to_string()).collect()
101 }
102
103 pub fn parse(raw: &str) -> Result<Self, CliError> {
109 match raw.trim().to_ascii_lowercase().as_str() {
110 "settlement" => Ok(Self::Settlement),
111 "mevblocker_backrun" => Ok(Self::MevblockerBackrun),
112 "txpool_backrun" => Ok(Self::TxpoolBackrun),
113 other => Err(CliError::InvalidArgument(format!(
114 "unknown strategy facet {other:?} (expected one of: settlement \
115 mevblocker_backrun txpool_backrun)"
116 ))),
117 }
118 }
119}
120
121#[expect(clippy::panic)] fn facet_key(section: &str, field: &str) -> &'static degenbot_config::KeyDecl {
129 SCHEMA
130 .iter()
131 .find(|k| k.section == section && k.field == field)
132 .unwrap_or_else(|| panic!("{section}.{field} must be declared"))
133}
134
135#[derive(Debug, Clone, PartialEq, Eq)]
139pub struct StrategyFacetDescriptor {
140 pub facet: StrategyFacet,
142 pub config_section: &'static str,
144 pub trigger_kind: &'static str,
146 pub fields: Vec<&'static str>,
149 pub envs: Vec<&'static str>,
152}
153
154#[must_use]
156pub fn descriptor(facet: StrategyFacet) -> StrategyFacetDescriptor {
157 let section = facet.config_section();
158 let mut fields = Vec::new();
159 let mut envs = Vec::new();
160 for key in SCHEMA {
161 if key.section == section {
162 fields.push(key.field);
163 envs.push(key.env);
164 }
165 }
166 StrategyFacetDescriptor {
167 facet,
168 config_section: section,
169 trigger_kind: facet.trigger_kind(),
170 fields,
171 envs,
172 }
173}
174
175#[must_use]
177pub fn descriptors() -> Vec<StrategyFacetDescriptor> {
178 StrategyFacet::ALL.into_iter().map(descriptor).collect()
179}
180
181#[derive(Debug, Clone, PartialEq, Eq)]
183pub enum EndpointSummary {
184 Inactive,
186 Unset,
188 Default(Vec<String>),
190 Explicit(Vec<String>),
192}
193
194#[derive(Debug, Clone, PartialEq, Eq)]
196pub enum StrategyCommand {
197 List,
199 Show {
202 facet: StrategyFacet,
204 },
205 Activate {
210 facet: StrategyFacet,
212 endpoints: Option<String>,
214 endpoints_default: bool,
216 },
217 Deactivate {
220 facet: StrategyFacet,
222 },
223 Set {
225 facet: StrategyFacet,
227 key: String,
229 value: String,
231 },
232 Default {
235 facet: StrategyFacet,
237 key: String,
239 },
240 Remove {
243 facet: StrategyFacet,
245 key: String,
247 },
248}
249
250impl StrategyCommand {
251 #[must_use]
253 pub const fn prompt_plan(&self, _ctx: &CliContext<'_>) -> PromptPlan {
254 PromptPlan::None
255 }
256}
257
258#[derive(Debug, Clone, PartialEq, Eq)]
260pub enum MutationOutcome {
261 Applied,
263 Shadowed {
266 env: String,
269 },
270}
271
272impl From<WriteOutcome> for MutationOutcome {
273 fn from(outcome: WriteOutcome) -> Self {
274 match outcome {
275 WriteOutcome::Written => Self::Applied,
276 WriteOutcome::Shadowed { env } => Self::Shadowed { env },
277 }
278 }
279}
280
281pub(crate) fn execute(
288 command: &StrategyCommand,
289 ctx: &CliContext<'_>,
290 _prompter: &dyn crate::prompt::Prompter,
291) -> Result<StrategyReport, CliError> {
292 match command {
293 StrategyCommand::List => Ok(StrategyReport::Listed {
294 rows: descriptors(),
295 }),
296 StrategyCommand::Show { facet } => {
297 let file = ctx.resolve_config_file()?;
298 let loaded = load(&file, ctx)?;
299 let readiness = strategy_readiness(&loaded.config);
300 let summary = endpoint_summary(*facet, &loaded.config, &readiness);
301 Ok(StrategyReport::Shown {
302 descriptor: descriptor(*facet),
303 activation: Some(summary),
304 })
305 }
306 StrategyCommand::Activate {
307 facet,
308 endpoints,
309 endpoints_default,
310 } => {
311 let file = ctx.resolve_config_file()?;
312 activate(*facet, endpoints.as_ref(), *endpoints_default, &file, ctx)
313 }
314 StrategyCommand::Deactivate { facet } => {
315 let file = ctx.resolve_config_file()?;
316 let outcome = write_key_with_env(&file, facet.activation_key(), "false", ctx.env())
317 .map_err(|error| strategy_write_error(&error))?
318 .into();
319 Ok(StrategyReport::Deactivated {
320 facet: *facet,
321 outcome,
322 })
323 }
324 StrategyCommand::Set { facet, key, value } => {
325 let file = ctx.resolve_config_file()?;
326 let key = facet_lookup(*facet, key)?;
327 let outcome = write_key_with_env(&file, key, value, ctx.env())
328 .map_err(|error| strategy_write_error(&error))?
329 .into();
330 Ok(StrategyReport::Set {
331 facet: *facet,
332 key: key.field,
333 outcome,
334 })
335 }
336 StrategyCommand::Default { facet, key } | StrategyCommand::Remove { facet, key } => {
337 let file = ctx.resolve_config_file()?;
338 let key = facet_lookup(*facet, key)?;
339 let loaded = load(&file, ctx)?;
340 if loaded.source_of(key.env) != Some(degenbot_config::loader::Source::File) {
341 return Err(CliError::InvalidArgument(format!(
342 "{} has no file override to remove (its present value is default or env)",
343 key.toml_path
344 )));
345 }
346 degenbot_config::writer::remove_key(&file, key)
347 .map_err(|error| strategy_write_error(&error))?;
348 Ok(StrategyReport::Defaulted {
349 facet: *facet,
350 key: key.field,
351 })
352 }
353 }
354}
355
356fn endpoint_summary(
358 facet: StrategyFacet,
359 cfg: °enbot_config::schema::BotConfig,
360 readiness: &Result<degenbot_config::StrategyReadiness, degenbot_config::StrategyReadinessError>,
361) -> EndpointSummary {
362 let active = match facet {
363 StrategyFacet::Settlement => cfg.strategy.settlement.active,
364 StrategyFacet::MevblockerBackrun => cfg.strategy.mevblocker_backrun.active,
365 StrategyFacet::TxpoolBackrun => cfg.strategy.txpool_backrun.active,
366 };
367 if !active {
368 return EndpointSummary::Inactive;
369 }
370 match readiness {
371 Ok(readiness) => {
372 let arm = match facet {
373 StrategyFacet::Settlement => &readiness.settlement,
374 StrategyFacet::MevblockerBackrun => &readiness.mevblocker_backrun,
375 StrategyFacet::TxpoolBackrun => &readiness.txpool_backrun,
376 };
377 match arm {
378 StrategyArm::Active(urls) => {
379 if *urls == facet.default_endpoint_set() {
383 EndpointSummary::Default(urls.clone())
384 } else {
385 EndpointSummary::Explicit(urls.clone())
386 }
387 }
388 StrategyArm::Inactive => EndpointSummary::Unset,
389 }
390 }
391 Err(_) => EndpointSummary::Unset,
392 }
393}
394
395fn load(file: &Path, ctx: &CliContext<'_>) -> Result<degenbot_config::LoadedConfig, CliError> {
397 ctx.load_bot_config_at(file)
398}
399
400fn activate(
403 facet: StrategyFacet,
404 endpoints: Option<&String>,
405 endpoints_default: bool,
406 file: &Path,
407 ctx: &CliContext<'_>,
408) -> Result<StrategyReport, CliError> {
409 if endpoints.is_some() && endpoints_default {
410 return Err(CliError::InvalidArgument(format!(
411 "strategy {}: choose exactly one of --endpoints and --endpoints-default",
412 facet.as_str()
413 )));
414 }
415 let stamped = endpoints_default.then(|| facet.default_endpoint_set().join(","));
419 let chosen = endpoints.cloned().or(stamped);
420 let loaded = load(file, ctx)?;
421 let already_settled = facet_has_settled_choice(facet, &loaded.config);
422 if chosen.is_none() && !already_settled {
423 return Err(CliError::InvalidArgument(format!(
424 "activating strategy {} requires an endpoint choice: pass exactly one of \
425 --endpoints <urls> or --endpoints-default",
426 facet.as_str()
427 )));
428 }
429
430 let mut candidate = loaded.config.clone();
434 if let Some(urls) = &chosen {
435 candidate
436 .assign(
437 facet.endpoints_key().section,
438 facet.endpoints_key().field,
439 urls,
440 )
441 .map_err(CliError::InvalidArgument)?;
442 }
443 let activation = facet.activation_key();
444 candidate
445 .assign(activation.section, activation.field, "true")
446 .map_err(CliError::InvalidArgument)?;
447 let readiness = strategy_readiness(&candidate)
448 .map_err(|error| CliError::InvalidArgument(error.to_string()))?;
449 let arm = match facet {
450 StrategyFacet::Settlement => &readiness.settlement,
451 StrategyFacet::MevblockerBackrun => &readiness.mevblocker_backrun,
452 StrategyFacet::TxpoolBackrun => &readiness.txpool_backrun,
453 };
454 let StrategyArm::Active(urls) = arm else {
455 return Err(CliError::InvalidArgument(format!(
456 "strategy {} did not settle to an active arm: this is a CLI bug",
457 facet.as_str()
458 )));
459 };
460
461 let mut outcome = MutationOutcome::Applied;
463 if let Some(urls) = &chosen {
464 outcome = write_key_with_env(file, facet.endpoints_key(), urls, ctx.env())
465 .map_err(|error| strategy_write_error(&error))?
466 .into();
467 }
468 let activation_outcome = write_key_with_env(file, facet.activation_key(), "true", ctx.env())
469 .map_err(|error| strategy_write_error(&error))?
470 .into();
471 if matches!(outcome, MutationOutcome::Applied) {
472 outcome = activation_outcome;
473 }
474
475 let summary = if endpoints_default {
476 EndpointSummary::Default(urls.clone())
477 } else {
478 EndpointSummary::Explicit(urls.clone())
479 };
480 Ok(StrategyReport::Activated {
481 facet,
482 summary,
483 outcome,
484 })
485}
486
487fn facet_has_settled_choice(
489 facet: StrategyFacet,
490 cfg: °enbot_config::schema::BotConfig,
491) -> bool {
492 let endpoints = match facet {
493 StrategyFacet::Settlement => cfg.strategy.settlement.endpoints.as_deref(),
494 StrategyFacet::MevblockerBackrun => cfg.strategy.mevblocker_backrun.endpoints.as_deref(),
495 StrategyFacet::TxpoolBackrun => cfg.strategy.txpool_backrun.endpoints.as_deref(),
496 };
497 endpoints.is_some_and(|raw| raw.split(',').any(|s| !s.trim().is_empty()))
498}
499
500fn facet_lookup(
502 facet: StrategyFacet,
503 key: &str,
504) -> Result<&'static degenbot_config::KeyDecl, CliError> {
505 let matched = SCHEMA
506 .iter()
507 .find(|k| k.section == facet.config_section() && k.field == key);
508 let Some(key) = matched else {
509 let declared = descriptor(facet).fields.clone().join(", ");
510 return Err(CliError::InvalidArgument(format!(
511 "unknown key {key:?} on facet {} (declared: {declared})",
512 facet.config_section()
513 )));
514 };
515 if matches!(key.field, "active" | "endpoints") {
518 return Err(CliError::InvalidArgument(format!(
519 "key {key:?} is owned by `strategy activate`; use the activate verb \
520 (or --endpoints / --endpoints-default) instead"
521 )));
522 }
523 Ok(key)
524}
525
526fn strategy_write_error(error: °enbot_config::ConfigError) -> CliError {
528 CliError::InvalidArgument(error.to_string())
529}
530
531#[cfg(test)]
532#[expect(
533 clippy::unwrap_used,
534 clippy::expect_used,
535 clippy::panic,
536 reason = "unit tests: a malformed fixture must fail the test loudly"
537)]
538mod tests {
539 use super::*;
540 use crate::report::StrategyReport;
541 use degenbot_config::{BotConfigLoader, MapEnv, SECTION_PATHS};
542 use std::collections::BTreeMap;
543 use std::path::PathBuf;
544
545 struct NoPrompt;
546 impl crate::prompt::Prompter for NoPrompt {
547 fn confirm(&self, _message: &str, default: bool) -> bool {
548 default
549 }
550 }
551
552 fn test_file(name: &str) -> PathBuf {
553 let dir = std::env::temp_dir().join(format!("strategy-cli-{name}-{}", std::process::id()));
554 let file = dir.join("config.toml");
555 let _ = std::fs::remove_file(&file);
556 file
557 }
558
559 fn empty_env() -> MapEnv {
560 MapEnv::new(BTreeMap::new())
561 }
562
563 fn ctx_at<'e>(env: &'e MapEnv, file: &Path) -> CliContext<'e> {
564 let path = file.to_string_lossy().into_owned();
565 CliContext::new(env).with_config(path)
566 }
567
568 #[test]
569 fn facets_name_their_typed_sections_and_trigger_kinds() {
570 for descriptor in descriptors() {
571 assert!(
572 SECTION_PATHS.contains(&descriptor.config_section),
573 "{} must be a declared schema section",
574 descriptor.config_section
575 );
576 }
577 }
578
579 #[test]
580 fn facet_descriptors_mirror_their_schema_keys() {
581 for descriptor in descriptors() {
582 let expected_fields: Vec<&str> = SCHEMA
583 .iter()
584 .filter(|key| key.section == descriptor.config_section)
585 .map(|key| key.field)
586 .collect();
587 assert_eq!(descriptor.fields, expected_fields);
588 let expected_envs: Vec<&str> = SCHEMA
589 .iter()
590 .filter(|key| key.section == descriptor.config_section)
591 .map(|key| key.env)
592 .collect();
593 assert_eq!(descriptor.envs, expected_envs);
594 }
595 assert!(
596 !descriptor(StrategyFacet::Settlement).fields.is_empty(),
597 "strategy.settlement declares its keys"
598 );
599 }
600
601 #[test]
602 fn facet_selector_parses_case_insensitively_and_refuses_unknown() {
603 assert_eq!(
604 StrategyFacet::parse("Settlement").unwrap(),
605 StrategyFacet::Settlement
606 );
607 assert_eq!(
608 StrategyFacet::parse(" mevblocker_backrun ").unwrap(),
609 StrategyFacet::MevblockerBackrun
610 );
611 assert_eq!(
612 StrategyFacet::parse("txpool_backrun").unwrap(),
613 StrategyFacet::TxpoolBackrun
614 );
615 assert!(
616 StrategyFacet::parse("backrun").is_err(),
617 "the retired single backrun spelling must not resolve"
618 );
619 assert!(StrategyFacet::parse("sandwich").is_err());
620 }
621
622 #[test]
623 fn both_backrun_facets_activate_independently() {
624 let file = test_file("activate-both-backruns");
625 let env = empty_env();
626 let ctx = ctx_at(&env, &file);
627 execute(
628 &StrategyCommand::Activate {
629 facet: StrategyFacet::MevblockerBackrun,
630 endpoints: None,
631 endpoints_default: true,
632 },
633 &ctx,
634 &NoPrompt,
635 )
636 .expect("activate mevblocker");
637 execute(
638 &StrategyCommand::Activate {
639 facet: StrategyFacet::TxpoolBackrun,
640 endpoints: None,
641 endpoints_default: true,
642 },
643 &ctx,
644 &NoPrompt,
645 )
646 .expect("activate peer");
647 let loaded = BotConfigLoader::new()
648 .with_config_path(&file)
649 .without_env()
650 .load()
651 .expect("config loads");
652 assert!(loaded.config.strategy.mevblocker_backrun.active);
653 assert!(loaded.config.strategy.txpool_backrun.active);
654 assert_eq!(
655 loaded.config.strategy.txpool_backrun.endpoints.as_deref(),
656 Some(
657 degenbot_config::DEFAULT_TXPOOL_BACKRUN_RELAYS
658 .join(",")
659 .as_str()
660 )
661 );
662 }
663
664 #[test]
667 fn list_reports_every_facet() {
668 let env = empty_env();
669 let ctx = CliContext::new(&env);
670 let report = execute(&StrategyCommand::List, &ctx, &NoPrompt).expect("list executes");
671 let StrategyReport::Listed { rows } = report else {
672 panic!("list must produce Listed");
673 };
674 assert_eq!(rows.len(), StrategyFacet::ALL.len());
675 }
676
677 #[test]
678 fn show_reports_activation_and_posture() {
679 let file = test_file("show");
680 let env = empty_env();
681 let ctx = ctx_at(&env, &file);
682 execute(
684 &StrategyCommand::Activate {
685 facet: StrategyFacet::MevblockerBackrun,
686 endpoints: None,
687 endpoints_default: true,
688 },
689 &ctx,
690 &NoPrompt,
691 )
692 .expect("activate executes");
693 let report = execute(
694 &StrategyCommand::Show {
695 facet: StrategyFacet::MevblockerBackrun,
696 },
697 &ctx,
698 &NoPrompt,
699 )
700 .expect("show executes");
701 let StrategyReport::Shown {
702 activation: Some(summary),
703 ..
704 } = report
705 else {
706 panic!("show must produce Shown with activation");
707 };
708 assert_eq!(
709 summary,
710 EndpointSummary::Default(vec![degenbot_config::DEFAULT_BACKRUN_STREAM_URL.to_string()])
711 );
712 }
713
714 #[test]
717 fn activate_with_endpoints_default_stamps_the_pinned_set_and_active() {
718 let file = test_file("activate-default");
719 let env = empty_env();
720 let ctx = ctx_at(&env, &file);
721 execute(
722 &StrategyCommand::Activate {
723 facet: StrategyFacet::MevblockerBackrun,
724 endpoints: None,
725 endpoints_default: true,
726 },
727 &ctx,
728 &NoPrompt,
729 )
730 .expect("activate executes");
731 let loaded = BotConfigLoader::new()
732 .with_config_path(&file)
733 .without_env()
734 .load()
735 .expect("written config loads");
736 assert!(loaded.config.strategy.mevblocker_backrun.active);
737 assert_eq!(
738 loaded
739 .config
740 .strategy
741 .mevblocker_backrun
742 .endpoints
743 .as_deref(),
744 Some(degenbot_config::DEFAULT_BACKRUN_STREAM_URL),
745 "the pinned default must be stamped into the persisted list"
746 );
747 }
748
749 #[test]
750 fn activate_with_explicit_endpoints_writes_them() {
751 let file = test_file("activate-explicit");
752 let env = empty_env();
753 let ctx = ctx_at(&env, &file);
754 execute(
755 &StrategyCommand::Activate {
756 facet: StrategyFacet::Settlement,
757 endpoints: Some("https://rpc.mevblocker.io/noreverts".to_string()),
758 endpoints_default: false,
759 },
760 &ctx,
761 &NoPrompt,
762 )
763 .expect("activate executes");
764 let loaded = BotConfigLoader::new()
765 .with_config_path(&file)
766 .without_env()
767 .load()
768 .expect("written config loads");
769 assert!(loaded.config.strategy.settlement.active);
770 assert_eq!(
771 loaded.config.strategy.settlement.endpoints.as_deref(),
772 Some("https://rpc.mevblocker.io/noreverts")
773 );
774 }
775
776 #[test]
777 fn a_refused_activation_leaves_no_file_residue() {
778 let file = test_file("activate-atomic");
779 let env = empty_env();
780 let ctx = ctx_at(&env, &file);
781 let error = execute(
782 &StrategyCommand::Activate {
783 facet: StrategyFacet::Settlement,
784 endpoints: Some("https://rpc.mevblocker.io/fast".to_string()),
785 endpoints_default: false,
786 },
787 &ctx,
788 &NoPrompt,
789 )
790 .expect_err("off-allowlist activation must refuse");
791 assert!(
792 error.message().contains("RELAYS_AND_GUARDRAILS"),
793 "{}",
794 error.message()
795 );
796 assert!(
797 !file.exists(),
798 "a refused activation must not create or touch the config file"
799 );
800 }
801
802 #[test]
803 fn activate_without_a_choice_refuses_naming_both_flags() {
804 let file = test_file("activate-nochoice");
805 let env = empty_env();
806 let ctx = ctx_at(&env, &file);
807 let error = execute(
808 &StrategyCommand::Activate {
809 facet: StrategyFacet::Settlement,
810 endpoints: None,
811 endpoints_default: false,
812 },
813 &ctx,
814 &NoPrompt,
815 )
816 .expect_err("unsettled activation must refuse");
817 let message = error.message();
818 assert!(message.contains("--endpoints"), "{message}");
819 assert!(message.contains("--endpoints-default"), "{message}");
820 assert!(!file.exists() || std::fs::read_to_string(&file).expect("read").is_empty());
822 }
823
824 #[test]
825 fn activate_with_both_choices_refuses() {
826 let file = test_file("activate-both");
827 let env = empty_env();
828 let ctx = ctx_at(&env, &file);
829 let error = execute(
830 &StrategyCommand::Activate {
831 facet: StrategyFacet::MevblockerBackrun,
832 endpoints: Some("wss://searchers.example".to_string()),
833 endpoints_default: true,
834 },
835 &ctx,
836 &NoPrompt,
837 )
838 .expect_err("both choices must refuse");
839 assert!(
840 error.message().contains("exactly one"),
841 "{}",
842 error.message()
843 );
844 }
845
846 #[test]
847 fn activate_refuses_off_allowlist_settlement_endpoints_at_write_time() {
848 let file = test_file("activate-offallowlist");
849 let env = empty_env();
850 let ctx = ctx_at(&env, &file);
851 let error = execute(
852 &StrategyCommand::Activate {
853 facet: StrategyFacet::Settlement,
854 endpoints: Some("https://rpc.mevblocker.io/fast".to_string()),
855 endpoints_default: false,
856 },
857 &ctx,
858 &NoPrompt,
859 )
860 .expect_err("off-allowlist endpoints must refuse at write time");
861 assert!(
862 error.message().contains("RELAYS_AND_GUARDRAILS"),
863 "{}",
864 error.message()
865 );
866 }
867
868 #[test]
869 fn reactivating_with_a_new_choice_overwrites_the_old() {
870 let file = test_file("activate-rewrite");
871 let env = empty_env();
872 let ctx = ctx_at(&env, &file);
873 execute(
874 &StrategyCommand::Activate {
875 facet: StrategyFacet::MevblockerBackrun,
876 endpoints: Some("wss://searchers.example".to_string()),
877 endpoints_default: false,
878 },
879 &ctx,
880 &NoPrompt,
881 )
882 .expect("initial activate");
883 execute(
885 &StrategyCommand::Activate {
886 facet: StrategyFacet::MevblockerBackrun,
887 endpoints: None,
888 endpoints_default: true,
889 },
890 &ctx,
891 &NoPrompt,
892 )
893 .expect("re-activation executes");
894 let loaded = BotConfigLoader::new()
895 .with_config_path(&file)
896 .without_env()
897 .load()
898 .expect("config loads");
899 assert!(loaded.config.strategy.mevblocker_backrun.active);
900 assert_eq!(
901 loaded
902 .config
903 .strategy
904 .mevblocker_backrun
905 .endpoints
906 .as_deref(),
907 Some(degenbot_config::DEFAULT_BACKRUN_STREAM_URL),
908 "the default re-activation must restamp the pinned list"
909 );
910 }
911
912 #[test]
913 fn activate_reports_an_env_shadow_loudly() {
914 let file = test_file("activate-shadowed");
915 let env = MapEnv::new(BTreeMap::from([(
916 "DEGENBOT_STRATEGY_MEVBLOCKER_BACKRUN_ACTIVE".to_string(),
917 "false".to_string(),
918 )]));
919 let ctx = ctx_at(&env, &file);
920 let report = execute(
921 &StrategyCommand::Activate {
922 facet: StrategyFacet::MevblockerBackrun,
923 endpoints: None,
924 endpoints_default: true,
925 },
926 &ctx,
927 &NoPrompt,
928 )
929 .expect("activate executes");
930 let StrategyReport::Activated {
931 outcome: MutationOutcome::Shadowed { env },
932 ..
933 } = report
934 else {
935 panic!("the shadow must reach the report");
936 };
937 assert_eq!(env, "DEGENBOT_STRATEGY_MEVBLOCKER_BACKRUN_ACTIVE");
938 }
939
940 #[test]
941 fn deactivate_leaves_the_recorded_choice_alone() {
942 let file = test_file("deactivate");
943 let env = empty_env();
944 let ctx = ctx_at(&env, &file);
945 execute(
946 &StrategyCommand::Activate {
947 facet: StrategyFacet::Settlement,
948 endpoints: None,
949 endpoints_default: true,
950 },
951 &ctx,
952 &NoPrompt,
953 )
954 .expect("activate");
955 execute(
956 &StrategyCommand::Deactivate {
957 facet: StrategyFacet::Settlement,
958 },
959 &ctx,
960 &NoPrompt,
961 )
962 .expect("deactivate");
963 let loaded = BotConfigLoader::new()
964 .with_config_path(&file)
965 .without_env()
966 .load()
967 .expect("config loads");
968 assert!(!loaded.config.strategy.settlement.active);
969 assert_eq!(
970 loaded.config.strategy.settlement.endpoints.as_deref(),
971 Some(
972 degenbot_config::SETTLEMENT_DEFAULT_ENDPOINTS
973 .join(",")
974 .as_str()
975 ),
976 "deactivation keeps the recorded endpoint set for re-activation"
977 );
978 }
979
980 #[test]
983 fn set_writes_a_typed_override_and_default_restores_it() {
984 let file = test_file("set-default");
985 let env = empty_env();
986 let ctx = ctx_at(&env, &file);
987 execute(
988 &StrategyCommand::Set {
989 facet: StrategyFacet::MevblockerBackrun,
990 key: "priority_fee_gwei".to_string(),
991 value: "7".to_string(),
992 },
993 &ctx,
994 &NoPrompt,
995 )
996 .expect("set executes");
997 let loaded = BotConfigLoader::new()
998 .with_config_path(&file)
999 .without_env()
1000 .load()
1001 .expect("config loads");
1002 assert_eq!(
1003 loaded.config.strategy.mevblocker_backrun.priority_fee_gwei,
1004 7
1005 );
1006
1007 execute(
1008 &StrategyCommand::Default {
1009 facet: StrategyFacet::MevblockerBackrun,
1010 key: "priority_fee_gwei".to_string(),
1011 },
1012 &ctx,
1013 &NoPrompt,
1014 )
1015 .expect("default executes");
1016 let restored = BotConfigLoader::new()
1017 .with_config_path(&file)
1018 .without_env()
1019 .load()
1020 .expect("config loads");
1021 assert_eq!(
1022 restored
1023 .config
1024 .strategy
1025 .mevblocker_backrun
1026 .priority_fee_gwei,
1027 2
1028 );
1029 }
1030
1031 #[test]
1032 fn remove_is_the_default_verb_alias() {
1033 let file = test_file("remove-alias");
1034 let env = empty_env();
1035 let ctx = ctx_at(&env, &file);
1036 execute(
1037 &StrategyCommand::Set {
1038 facet: StrategyFacet::MevblockerBackrun,
1039 key: "bundle_gas_est".to_string(),
1040 value: "333000".to_string(),
1041 },
1042 &ctx,
1043 &NoPrompt,
1044 )
1045 .expect("set executes");
1046 execute(
1047 &StrategyCommand::Remove {
1048 facet: StrategyFacet::MevblockerBackrun,
1049 key: "bundle_gas_est".to_string(),
1050 },
1051 &ctx,
1052 &NoPrompt,
1053 )
1054 .expect("remove executes");
1055 let restored = BotConfigLoader::new()
1056 .with_config_path(&file)
1057 .without_env()
1058 .load()
1059 .expect("config loads");
1060 assert_eq!(
1061 restored.config.strategy.mevblocker_backrun.bundle_gas_est,
1062 300_000
1063 );
1064 }
1065
1066 #[test]
1067 fn set_refuses_unknown_and_activation_owned_keys() {
1068 let file = test_file("set-refuse");
1069 let env = empty_env();
1070 let ctx = ctx_at(&env, &file);
1071 for (key, expectation) in [
1072 ("no_such_key", "declared"),
1073 ("log_stderr", "declared"),
1074 ("active", "activate verb"),
1075 ("endpoints", "activate verb"),
1076 ] {
1077 let error = execute(
1078 &StrategyCommand::Set {
1079 facet: StrategyFacet::MevblockerBackrun,
1080 key: key.to_string(),
1081 value: "1".to_string(),
1082 },
1083 &ctx,
1084 &NoPrompt,
1085 )
1086 .expect_err("key must refuse");
1087 assert!(
1088 error.message().contains(expectation),
1089 "{key}: {}",
1090 error.message()
1091 );
1092 }
1093 }
1094
1095 #[test]
1096 fn default_refuses_when_no_override_is_present() {
1097 let file = test_file("default-noop");
1098 let env = empty_env();
1099 let ctx = ctx_at(&env, &file);
1100 let error = execute(
1101 &StrategyCommand::Default {
1102 facet: StrategyFacet::MevblockerBackrun,
1103 key: "priority_fee_gwei".to_string(),
1104 },
1105 &ctx,
1106 &NoPrompt,
1107 )
1108 .expect_err("no-op default must refuse");
1109 assert!(
1110 error.message().contains("no file override"),
1111 "{}",
1112 error.message()
1113 );
1114 }
1115}