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