1use std::io::Write as _;
37
38use clap::{ArgAction, Args, CommandFactory as _, Parser, Subcommand, ValueEnum};
39use degenbot_cli_core::{
40 AaveCommand, CliContext, CliError, Command, ConfigCommand, DatabaseCommand, ExchangeCommand,
41 FleetCommand, PathCommand, PathDirection, PoolCommand, PoolFamily, PosturePatchEntry,
42 StrategyCommand, StrategyFacet, DEFAULT_CHUNK_SIZE, DEFAULT_TO_BLOCK,
43 DEFAULT_VERIFY_ALL_INTERVAL,
44};
45use degenbot_config::{EnvVars, NodeTransport, ProcessEnv, DEFAULT_CHAIN_ID_ENV};
46
47use crate::VERSION_LINE;
48
49#[derive(Debug, Parser)]
51#[command(
52 name = "degenbot",
53 about = "Perform cli.",
54 version = VERSION_LINE,
55 arg_required_else_help = true,
56 disable_help_subcommand = true
57)]
58pub struct Cli {
59 #[command(subcommand)]
61 pub command: Option<Commands>,
62
63 #[arg(long, global = true, value_name = "PATH")]
66 pub database: Option<String>,
67
68 #[arg(long, global = true, value_name = "CHAIN_ID")]
70 pub chain_id: Option<String>,
71
72 #[arg(long = "node", global = true, value_name = "URI", value_parser = parse_node)]
78 pub node: Vec<NodeArg>,
79
80 #[arg(long, global = true, value_name = "PATH")]
83 pub config: Option<String>,
84}
85
86#[derive(Debug, Subcommand)]
88pub enum Commands {
89 Database {
91 #[command(subcommand)]
93 command: DatabaseSub,
94 },
95 Exchange {
97 #[command(subcommand)]
99 command: ExchangeSub,
100 },
101 Pool {
103 #[command(subcommand)]
105 command: PoolSub,
106 },
107 Aave {
109 #[command(subcommand)]
111 command: AaveSub,
112 },
113 Fleet {
115 #[command(subcommand)]
117 command: FleetSub,
118 },
119 Path {
121 #[command(subcommand)]
123 command: PathSub,
124 },
125 Config {
127 #[command(subcommand)]
129 command: ConfigSub,
130 },
131 Strategy {
133 #[command(subcommand)]
135 command: StrategySub,
136 },
137}
138
139#[derive(Debug, Subcommand)]
141pub enum DatabaseSub {
142 Backup,
144 Reset {
146 #[arg(long)]
148 force: bool,
149 },
150 Upgrade {
153 #[arg(long)]
155 force: bool,
156 },
157 Compact,
159 Cutover {
161 #[arg(long)]
163 dry_run: bool,
164 #[arg(long)]
166 force: bool,
167 },
168 Heal {
171 #[arg(long)]
173 dry_run: bool,
174 #[arg(long)]
176 force: bool,
177 },
178 Inspect,
180}
181
182#[derive(Debug, Subcommand)]
184pub enum ExchangeSub {
185 Activate {
188 #[arg(long, value_name = "CHAIN")]
191 chain: String,
192 #[arg(long, value_name = "NAME")]
195 name: String,
196 },
197 Deactivate {
200 #[arg(long, value_name = "CHAIN")]
203 chain: String,
204 #[arg(long, value_name = "NAME")]
206 name: String,
207 },
208 List {
210 #[arg(long, value_name = "CHAIN")]
212 chain: Option<String>,
213 },
214}
215
216#[derive(Debug, Subcommand)]
218pub enum PoolSub {
219 Update {
221 #[arg(long = "chunk", value_name = "BLOCKS", default_value_t = DEFAULT_CHUNK_SIZE)]
224 chunk_size: u64,
225 #[arg(long = "to-block", value_name = "BLOCK", default_value = DEFAULT_TO_BLOCK)]
231 to_block: String,
232 #[command(flatten)]
234 verify: VerifyFlags,
235 #[arg(
239 long = "verify-all-interval",
240 value_name = "BLOCKS",
241 default_value_t = DEFAULT_VERIFY_ALL_INTERVAL
242 )]
243 verify_all_interval: u64,
244 },
245 Verify {
247 #[arg(long = "rpc-url", value_name = "URL", required = true)]
249 rpc_url: String,
250 #[arg(long = "chain", value_name = "CHAIN_ID", required = true)]
254 pool_chain_id: i64,
255 #[arg(long = "block", value_name = "BLOCK", required = true)]
257 block_number: u64,
258 #[arg(
259 long = "pool",
260 value_name = "POOL",
261 required = true,
262 help = "The pool to verify. V3: the pool contract address. V4: the PoolId (pool_hash, bytes32 hex 0x…)."
263 )]
264 pool: String,
265 #[arg(
266 long = "family",
267 value_enum,
268 required = true,
269 help = "The pool family (selects ticks()/tickBitmap() vs PoolManager extsload)."
270 )]
271 family: FamilyArg,
272 #[arg(
273 long = "pool-manager",
274 value_name = "ADDRESS",
275 help = "(V4 only) The deployed V4 PoolManager singleton address (the V4 exchange's factory). Required for --family v4."
276 )]
277 pool_manager: Option<String>,
278 },
279}
280
281#[derive(Debug, Clone, Args)]
283#[expect(
284 clippy::struct_excessive_bools,
285 reason = "the two on/off gate pairs are argv vocabulary, not state: each pair is two mutually-overriding flags"
286)]
287pub struct VerifyFlags {
288 #[arg(long = "verify-chunk", action = ArgAction::SetTrue, overrides_with = "no_verify_chunk")]
292 pub verify_chunk: bool,
293 #[arg(long = "no-verify-chunk", action = ArgAction::SetTrue, overrides_with = "verify_chunk")]
295 pub no_verify_chunk: bool,
296 #[arg(long = "verify-all", action = ArgAction::SetTrue, overrides_with = "no_verify_all")]
300 pub verify_all: bool,
301 #[arg(long = "no-verify-all", action = ArgAction::SetTrue, overrides_with = "verify_all")]
303 pub no_verify_all: bool,
304}
305
306impl VerifyFlags {
307 #[must_use]
309 pub const fn chunk_gate(&self) -> bool {
310 self.verify_chunk || !self.no_verify_chunk
311 }
312
313 #[must_use]
315 pub const fn all_gate(&self) -> bool {
316 self.verify_all && !self.no_verify_all
317 }
318}
319
320#[derive(Debug, Clone, Copy, ValueEnum)]
322pub enum FamilyArg {
323 V3,
325 V4,
327}
328
329#[derive(Debug, Subcommand)]
331pub enum AaveSub {
332 Activate,
335 Deactivate {
338 #[arg(
340 long = "name",
341 value_name = "MARKET",
342 default_value = "Aave Ethereum Market"
343 )]
344 market_name: String,
345 },
346 Update {
348 #[arg(long = "chunk", value_name = "BLOCKS", default_value_t = DEFAULT_CHUNK_SIZE)]
351 chunk_size: u64,
352 #[arg(long = "to-block", value_name = "BLOCK", default_value = DEFAULT_TO_BLOCK)]
358 to_block: String,
359 #[command(flatten)]
361 verify: VerifyFlags,
362 #[arg(
366 long = "verify-all-interval",
367 value_name = "BLOCKS",
368 default_value_t = DEFAULT_VERIFY_ALL_INTERVAL
369 )]
370 verify_all_interval: u64,
371 #[arg(long = "one-chunk", action = ArgAction::SetTrue)]
373 stop_after_one_chunk: bool,
374 #[arg(long = "dry-run", action = ArgAction::SetTrue)]
376 dry_run: bool,
377 #[arg(long = "backup", action = ArgAction::SetTrue, overrides_with = "no_backup")]
380 backup: bool,
381 #[arg(long = "no-backup", action = ArgAction::SetTrue, overrides_with = "backup")]
383 no_backup: bool,
384 },
385 Position {
387 #[command(subcommand)]
389 command: AavePositionSub,
390 },
391}
392
393#[derive(Debug, Subcommand)]
395pub enum AavePositionSub {
396 Show {
398 #[arg(value_name = "ADDRESS")]
400 address: String,
401 #[arg(
403 long = "market",
404 value_name = "MARKET",
405 default_value = "Aave Ethereum Market"
406 )]
407 market: String,
408 },
409}
410
411#[derive(Debug, Subcommand)]
413pub enum FleetSub {
414 Posture {
416 #[command(subcommand)]
418 command: FleetPostureSub,
419 },
420}
421
422#[derive(Debug, Subcommand)]
424pub enum FleetPostureSub {
425 Show {
427 #[arg(
428 long,
429 value_name = "PATH",
430 help = "Unix domain socket path of the running bot's OperatorServer."
431 )]
432 socket: Option<String>,
433 },
434 Set {
436 #[arg(
437 long,
438 value_name = "PATH",
439 help = "Unix domain socket path of the running bot's OperatorServer."
440 )]
441 socket: Option<String>,
442 #[arg(long = "cordon-enter-events", value_name = "COUNT")]
444 cordon_enter_events: Option<u64>,
445 #[arg(long = "cordon-duty-percent", value_name = "PERCENT")]
448 cordon_duty_percent: Option<f64>,
449 #[arg(long = "cordon-enter-window-ms", value_name = "MS")]
451 cordon_enter_window_ms: Option<u64>,
452 #[arg(long = "cordon-duty-window-ms", value_name = "MS")]
454 cordon_duty_window_ms: Option<u64>,
455 #[arg(long = "cordon-exit-clean-ms", value_name = "MS")]
457 cordon_exit_clean_ms: Option<u64>,
458 #[arg(
459 long = "cordon-sim-intake-floor",
460 value_name = "COUNT|null",
461 help = "SimDriver new-lease cap while cordoned. The literal null restores half the slot cap."
462 )]
463 cordon_sim_intake_floor: Option<String>,
464 },
465}
466
467#[derive(Debug, Subcommand)]
469pub enum PathSub {
470 Add {
472 #[arg(
473 long,
474 value_name = "PATH",
475 help = "Unix domain socket path of the running bot's OperatorServer."
476 )]
477 socket: Option<String>,
478 #[arg(long = "hop", value_name = "HOP", required = true)]
481 hops: Vec<String>,
482 #[arg(long = "direction", value_enum)]
486 direction: Option<DirectionArg>,
487 },
488 Discover {
490 #[arg(
491 long,
492 value_name = "PATH",
493 help = "Unix domain socket path of the running bot's OperatorServer."
494 )]
495 socket: Option<String>,
496 #[arg(long = "bound", value_name = "COUNT")]
498 bound: Option<u64>,
499 },
500}
501
502#[derive(Debug, Clone, Copy, ValueEnum)]
504pub enum DirectionArg {
505 Zfo,
507 Ozf,
509}
510
511#[derive(Debug, Subcommand)]
514pub enum StrategySub {
515 List,
517 Show {
520 #[arg(value_enum)]
522 facet: FacetArg,
523 },
524 Activate {
528 #[arg(value_enum)]
530 facet: FacetArg,
531 #[arg(
533 long = "endpoints",
534 value_name = "URLS",
535 conflicts_with = "endpoints_default"
536 )]
537 endpoints: Option<String>,
538 #[arg(long = "endpoints-default")]
540 endpoints_default: bool,
541 },
542 Deactivate {
544 #[arg(value_enum)]
546 facet: FacetArg,
547 },
548 Set {
550 #[arg(value_enum)]
552 facet: FacetArg,
553 key: String,
555 value: String,
557 },
558 Default {
561 #[arg(value_enum)]
563 facet: FacetArg,
564 key: String,
566 },
567 Remove {
569 #[arg(value_enum)]
571 facet: FacetArg,
572 key: String,
574 },
575}
576
577#[derive(Debug, Subcommand)]
579pub enum ConfigSub {
580 Show {
583 #[arg(long)]
585 resolved: bool,
586 },
587 Path,
589 Get {
591 key: String,
593 },
594 Set {
596 key: String,
598 value: String,
600 #[arg(long)]
602 force: bool,
603 },
604 Unset {
606 key: String,
608 #[arg(long)]
610 force: bool,
611 },
612}
613
614#[derive(Debug, Clone, Copy, ValueEnum)]
616pub enum FacetArg {
617 Settlement,
619 #[value(name = "mevblocker_backrun")]
621 MevblockerBackrun,
622 #[value(name = "txpool_backrun")]
624 TxpoolBackrun,
625}
626
627#[derive(Debug, Clone, PartialEq, Eq)]
631pub struct NodeArg {
632 pub uri: String,
634 pub transport: NodeTransport,
636}
637
638fn parse_node(raw: &str) -> Result<NodeArg, String> {
645 let Some(transport) = NodeTransport::classify(raw) else {
646 let forms: Vec<String> = NodeTransport::ALL
647 .iter()
648 .map(|transport| format!("{} — {}", transport.key_path(), transport.expected()))
649 .collect();
650 return Err(format!(
651 "{raw:?} names no node transport; accepted forms: {}",
652 forms.join("; ")
653 ));
654 };
655 Ok(NodeArg {
656 uri: raw.to_string(),
657 transport,
658 })
659}
660
661#[must_use]
663pub fn context<'a>(cli: &Cli, env: &'a dyn EnvVars) -> CliContext<'a> {
664 let mut ctx = CliContext::new(env);
665 if let Some(database) = &cli.database {
666 ctx = ctx.with_database(database.clone());
667 }
668 if let Some(chain_id) = &cli.chain_id {
669 ctx = ctx.with_chain_id(chain_id.clone());
670 }
671 for node in &cli.node {
672 ctx = ctx.with_node(node.uri.clone(), node.transport);
673 }
674 if let Some(config) = &cli.config {
675 ctx = ctx.with_config(config.clone());
676 }
677 ctx
678}
679
680pub fn resolve(cli: &Cli) -> Result<Command, CliError> {
688 resolve_with_env(cli, &ProcessEnv)
689}
690
691pub fn resolve_with_env(cli: &Cli, env: &dyn EnvVars) -> Result<Command, CliError> {
702 let ctx = context(cli, env);
703 let Some(command) = &cli.command else {
704 return Err(CliError::InvalidArgument(
705 "a subcommand is required".to_string(),
706 ));
707 };
708 match command {
709 Commands::Database { command } => Ok(Command::Database(database(command))),
710 Commands::Exchange { command } => Ok(Command::Exchange(exchange(command))),
711 Commands::Pool { command } => Ok(Command::Pool(pool(command))),
712 Commands::Aave { command } => Ok(Command::Aave(aave(command, cli, &ctx)?)),
713 Commands::Fleet { command } => Ok(Command::Fleet(fleet(command)?)),
714 Commands::Path { command } => Ok(Command::Path(path(command))),
715 Commands::Strategy { command } => Ok(Command::Strategy(strategy(command))),
716 Commands::Config { command } => Ok(Command::Config(config(command))),
717 }
718}
719
720pub fn write_missing_subcommand_error() {
722 let mut command = Cli::command();
723 let usage = command.render_usage().to_string();
724 let _ = writeln!(
725 std::io::stderr().lock(),
726 "error: a subcommand is required\n\n{usage}"
727 );
728}
729
730fn database(command: &DatabaseSub) -> DatabaseCommand {
731 match command {
732 DatabaseSub::Backup => DatabaseCommand::Backup,
733 DatabaseSub::Reset { force } => DatabaseCommand::Reset { force: *force },
734 DatabaseSub::Upgrade { force } => DatabaseCommand::Upgrade { force: *force },
735 DatabaseSub::Compact => DatabaseCommand::Compact,
736 DatabaseSub::Cutover { dry_run, force } => DatabaseCommand::Cutover {
737 dry_run: *dry_run,
738 force: *force,
739 },
740 DatabaseSub::Heal { dry_run, force } => DatabaseCommand::Heal {
741 dry_run: *dry_run,
742 force: *force,
743 },
744 DatabaseSub::Inspect => DatabaseCommand::Inspect,
745 }
746}
747
748fn exchange(command: &ExchangeSub) -> ExchangeCommand {
749 match command {
750 ExchangeSub::Activate { chain, name } => ExchangeCommand::Activate {
751 chain: chain.clone(),
752 name: name.clone(),
753 },
754 ExchangeSub::Deactivate { chain, name } => ExchangeCommand::Deactivate {
755 chain: chain.clone(),
756 name: name.clone(),
757 },
758 ExchangeSub::List { chain } => ExchangeCommand::List {
759 chain: chain.clone(),
760 },
761 }
762}
763
764fn pool(command: &PoolSub) -> PoolCommand {
765 match command {
766 PoolSub::Update {
767 chunk_size,
768 to_block,
769 verify,
770 verify_all_interval,
771 } => PoolCommand::Update {
772 chunk_size: *chunk_size,
773 to_block: to_block.clone(),
774 verify_chunk: verify.chunk_gate(),
775 verify_all: verify.all_gate(),
776 verify_all_interval: *verify_all_interval,
777 },
778 PoolSub::Verify {
779 rpc_url,
780 pool_chain_id,
781 block_number,
782 pool,
783 family,
784 pool_manager,
785 } => PoolCommand::Verify {
786 rpc_url: rpc_url.clone(),
787 chain_id: *pool_chain_id,
788 block_number: *block_number,
789 pool: pool.clone(),
790 family: match family {
791 FamilyArg::V3 => PoolFamily::V3,
792 FamilyArg::V4 => PoolFamily::V4,
793 },
794 pool_manager: pool_manager.clone(),
795 },
796 }
797}
798
799fn aave(command: &AaveSub, cli: &Cli, ctx: &CliContext<'_>) -> Result<AaveCommand, CliError> {
800 match command {
801 AaveSub::Activate => Ok(AaveCommand::Activate {
802 chain_id: chain_or_default(cli, ctx, 1)?,
803 }),
804 AaveSub::Deactivate { market_name } => Ok(AaveCommand::Deactivate {
805 chain_id: chain_or_default(cli, ctx, 1)?,
806 market_name: market_name.clone(),
807 }),
808 AaveSub::Update {
809 chunk_size,
810 to_block,
811 verify,
812 verify_all_interval,
813 stop_after_one_chunk,
814 dry_run,
815 backup,
816 no_backup,
817 } => Ok(AaveCommand::Update {
818 chunk_size: *chunk_size,
819 to_block: to_block.clone(),
820 verify_chunk: verify.chunk_gate(),
821 verify_all: verify.all_gate(),
822 verify_all_interval: *verify_all_interval,
823 stop_after_one_chunk: *stop_after_one_chunk,
824 dry_run: *dry_run,
825 enable_backup: *backup && !*no_backup,
826 }),
827 AaveSub::Position { command } => match command {
828 AavePositionSub::Show { address, market } => Ok(AaveCommand::PositionShow {
829 address: address.clone(),
830 market: market.clone(),
831 chain_id: chain_or_default(cli, ctx, 1)?,
832 }),
833 },
834 }
835}
836
837fn fleet(command: &FleetSub) -> Result<FleetCommand, CliError> {
838 match command {
839 FleetSub::Posture { command } => match command {
840 FleetPostureSub::Show { socket } => Ok(FleetCommand::PostureShow {
841 socket: socket.clone(),
842 }),
843 FleetPostureSub::Set {
844 socket,
845 cordon_enter_events,
846 cordon_duty_percent,
847 cordon_enter_window_ms,
848 cordon_duty_window_ms,
849 cordon_exit_clean_ms,
850 cordon_sim_intake_floor,
851 } => {
852 let mut patch = Vec::new();
853 if let Some(value) = cordon_enter_events {
854 patch.push(PosturePatchEntry::int("cordon_enter_events", *value));
855 }
856 if let Some(value) = cordon_duty_percent {
857 patch.push(PosturePatchEntry::float("cordon_duty_percent", *value));
858 }
859 if let Some(value) = cordon_enter_window_ms {
860 patch.push(PosturePatchEntry::int("cordon_enter_window_ms", *value));
861 }
862 if let Some(value) = cordon_duty_window_ms {
863 patch.push(PosturePatchEntry::int("cordon_duty_window_ms", *value));
864 }
865 if let Some(value) = cordon_exit_clean_ms {
866 patch.push(PosturePatchEntry::int("cordon_exit_clean_ms", *value));
867 }
868 if let Some(value) = cordon_sim_intake_floor {
869 patch.push(PosturePatchEntry::new(
870 "cordon_sim_intake_floor",
871 degenbot_cli_core::parse_sim_intake_floor(value)?,
872 ));
873 }
874 Ok(FleetCommand::PostureSet {
875 socket: socket.clone(),
876 patch,
877 })
878 }
879 },
880 }
881}
882
883fn path(command: &PathSub) -> PathCommand {
884 match command {
885 PathSub::Add {
886 socket,
887 hops,
888 direction,
889 } => PathCommand::Add {
890 socket: socket.clone(),
891 hops: hops.clone(),
892 direction: direction.map(|direction| match direction {
893 DirectionArg::Zfo => PathDirection::Zfo,
894 DirectionArg::Ozf => PathDirection::Ozf,
895 }),
896 },
897 PathSub::Discover { socket, bound } => PathCommand::Discover {
898 socket: socket.clone(),
899 bound: *bound,
900 },
901 }
902}
903
904fn strategy(command: &StrategySub) -> StrategyCommand {
905 match command {
906 StrategySub::List => StrategyCommand::List,
907 StrategySub::Show { facet } => StrategyCommand::Show {
908 facet: facet_of(*facet),
909 },
910 StrategySub::Activate {
911 facet,
912 endpoints,
913 endpoints_default,
914 } => StrategyCommand::Activate {
915 facet: facet_of(*facet),
916 endpoints: endpoints.clone(),
917 endpoints_default: *endpoints_default,
918 },
919 StrategySub::Deactivate { facet } => StrategyCommand::Deactivate {
920 facet: facet_of(*facet),
921 },
922 StrategySub::Set { facet, key, value } => StrategyCommand::Set {
923 facet: facet_of(*facet),
924 key: key.clone(),
925 value: value.clone(),
926 },
927 StrategySub::Default { facet, key } => StrategyCommand::Default {
928 facet: facet_of(*facet),
929 key: key.clone(),
930 },
931 StrategySub::Remove { facet, key } => StrategyCommand::Remove {
932 facet: facet_of(*facet),
933 key: key.clone(),
934 },
935 }
936}
937
938fn config(command: &ConfigSub) -> ConfigCommand {
939 match command {
940 ConfigSub::Show { resolved } => ConfigCommand::Show {
941 resolved: *resolved,
942 },
943 ConfigSub::Path => ConfigCommand::Path,
944 ConfigSub::Get { key } => ConfigCommand::Get { key: key.clone() },
945 ConfigSub::Set { key, value, force } => ConfigCommand::Set {
946 key: key.clone(),
947 value: value.clone(),
948 force: *force,
949 },
950 ConfigSub::Unset { key, force } => ConfigCommand::Unset {
951 key: key.clone(),
952 force: *force,
953 },
954 }
955}
956
957fn facet_of(arg: FacetArg) -> StrategyFacet {
958 match arg {
959 FacetArg::Settlement => StrategyFacet::Settlement,
960 FacetArg::MevblockerBackrun => StrategyFacet::MevblockerBackrun,
961 FacetArg::TxpoolBackrun => StrategyFacet::TxpoolBackrun,
962 }
963}
964
965fn chain_or_default(cli: &Cli, ctx: &CliContext<'_>, default: u64) -> Result<u64, CliError> {
969 let cli_layer = cli
970 .chain_id
971 .as_deref()
972 .is_some_and(|value| !value.is_empty());
973 let env_layer = ctx
974 .env()
975 .get(DEFAULT_CHAIN_ID_ENV)
976 .is_some_and(|value| !value.is_empty());
977 let file_layer = ctx.loaded_config()?.config.session.chain_id.is_some();
980 if cli_layer || env_layer || file_layer {
981 return Ok(ctx.chain_id()?.value);
982 }
983 Ok(default)
984}