1use std::io::Write as _;
37
38use clap::{ArgAction, Args, CommandFactory as _, Parser, Subcommand, ValueEnum};
39use degenbot_cli_core::{
40 AaveCommand, CliContext, CliError, Command, DatabaseCommand, ExchangeCommand, FleetCommand,
41 PathCommand, PathDirection, PoolCommand, PoolFamily, PosturePatchEntry, StrategyCommand,
42 StrategyFacet, DEFAULT_CHUNK_SIZE, DEFAULT_TO_BLOCK, DEFAULT_VERIFY_ALL_INTERVAL,
43};
44use degenbot_config::{EnvVars, ProcessEnv, DEFAULT_CHAIN_ID_ENV};
45
46use crate::VERSION_LINE;
47
48#[derive(Debug, Parser)]
50#[command(
51 name = "degenbot",
52 about = "Perform cli.",
53 version = VERSION_LINE,
54 arg_required_else_help = true,
55 disable_help_subcommand = true
56)]
57pub struct Cli {
58 #[command(subcommand)]
60 pub command: Option<Commands>,
61
62 #[arg(long, global = true, value_name = "PATH")]
65 pub database: Option<String>,
66
67 #[arg(long, global = true, value_name = "CHAIN_ID")]
69 pub chain_id: Option<String>,
70
71 #[arg(long, global = true, value_name = "URI")]
73 pub node_http: Option<String>,
74
75 #[arg(long, global = true, value_name = "URI")]
77 pub node_ws: Option<String>,
78
79 #[arg(long, global = true, value_name = "PATH")]
82 pub config: Option<String>,
83}
84
85#[derive(Debug, Subcommand)]
87pub enum Commands {
88 Database {
90 #[command(subcommand)]
92 command: DatabaseSub,
93 },
94 Exchange {
96 #[command(subcommand)]
98 command: ExchangeSub,
99 },
100 Pool {
102 #[command(subcommand)]
104 command: PoolSub,
105 },
106 Aave {
108 #[command(subcommand)]
110 command: AaveSub,
111 },
112 Fleet {
114 #[command(subcommand)]
116 command: FleetSub,
117 },
118 Path {
120 #[command(subcommand)]
122 command: PathSub,
123 },
124 Strategy {
126 #[command(subcommand)]
128 command: StrategySub,
129 },
130}
131
132#[derive(Debug, Subcommand)]
134pub enum DatabaseSub {
135 Backup,
137 Reset {
139 #[arg(long)]
141 force: bool,
142 },
143 Upgrade {
146 #[arg(long)]
148 force: bool,
149 },
150 Compact,
152 Cutover {
154 #[arg(long)]
156 dry_run: bool,
157 #[arg(long)]
159 force: bool,
160 },
161 Heal {
164 #[arg(long)]
166 dry_run: bool,
167 #[arg(long)]
169 force: bool,
170 },
171 Inspect,
173}
174
175#[derive(Debug, Subcommand)]
177pub enum ExchangeSub {
178 Activate {
181 #[arg(long, value_name = "CHAIN")]
184 chain: String,
185 #[arg(long, value_name = "NAME")]
188 name: String,
189 },
190 Deactivate {
193 #[arg(long, value_name = "CHAIN")]
196 chain: String,
197 #[arg(long, value_name = "NAME")]
199 name: String,
200 },
201 List {
203 #[arg(long, value_name = "CHAIN")]
205 chain: Option<String>,
206 },
207}
208
209#[derive(Debug, Subcommand)]
211pub enum PoolSub {
212 Update {
214 #[arg(long = "chunk", value_name = "BLOCKS", default_value_t = DEFAULT_CHUNK_SIZE)]
217 chunk_size: u64,
218 #[arg(long = "to-block", value_name = "BLOCK", default_value = DEFAULT_TO_BLOCK)]
224 to_block: String,
225 #[command(flatten)]
227 verify: VerifyFlags,
228 #[arg(
232 long = "verify-all-interval",
233 value_name = "BLOCKS",
234 default_value_t = DEFAULT_VERIFY_ALL_INTERVAL
235 )]
236 verify_all_interval: u64,
237 },
238 Verify {
240 #[arg(long = "rpc-url", value_name = "URL", required = true)]
242 rpc_url: String,
243 #[arg(long = "chain", value_name = "CHAIN_ID", required = true)]
247 pool_chain_id: i64,
248 #[arg(long = "block", value_name = "BLOCK", required = true)]
250 block_number: u64,
251 #[arg(
252 long = "pool",
253 value_name = "POOL",
254 required = true,
255 help = "The pool to verify. V3: the pool contract address. V4: the PoolId (pool_hash, bytes32 hex 0x…)."
256 )]
257 pool: String,
258 #[arg(
259 long = "family",
260 value_enum,
261 required = true,
262 help = "The pool family (selects ticks()/tickBitmap() vs PoolManager extsload)."
263 )]
264 family: FamilyArg,
265 #[arg(
266 long = "pool-manager",
267 value_name = "ADDRESS",
268 help = "(V4 only) The deployed V4 PoolManager singleton address (the V4 exchange's factory). Required for --family v4."
269 )]
270 pool_manager: Option<String>,
271 },
272}
273
274#[derive(Debug, Clone, Args)]
276#[expect(
277 clippy::struct_excessive_bools,
278 reason = "the two on/off gate pairs are argv vocabulary, not state: each pair is two mutually-overriding flags"
279)]
280pub struct VerifyFlags {
281 #[arg(long = "verify-chunk", action = ArgAction::SetTrue, overrides_with = "no_verify_chunk")]
285 pub verify_chunk: bool,
286 #[arg(long = "no-verify-chunk", action = ArgAction::SetTrue, overrides_with = "verify_chunk")]
288 pub no_verify_chunk: bool,
289 #[arg(long = "verify-all", action = ArgAction::SetTrue, overrides_with = "no_verify_all")]
293 pub verify_all: bool,
294 #[arg(long = "no-verify-all", action = ArgAction::SetTrue, overrides_with = "verify_all")]
296 pub no_verify_all: bool,
297}
298
299impl VerifyFlags {
300 #[must_use]
302 pub const fn chunk_gate(&self) -> bool {
303 self.verify_chunk || !self.no_verify_chunk
304 }
305
306 #[must_use]
308 pub const fn all_gate(&self) -> bool {
309 self.verify_all && !self.no_verify_all
310 }
311}
312
313#[derive(Debug, Clone, Copy, ValueEnum)]
315pub enum FamilyArg {
316 V3,
318 V4,
320}
321
322#[derive(Debug, Subcommand)]
324pub enum AaveSub {
325 Activate,
328 Deactivate {
331 #[arg(
333 long = "name",
334 value_name = "MARKET",
335 default_value = "Aave Ethereum Market"
336 )]
337 market_name: String,
338 },
339 Update {
341 #[arg(long = "chunk", value_name = "BLOCKS", default_value_t = DEFAULT_CHUNK_SIZE)]
344 chunk_size: u64,
345 #[arg(long = "to-block", value_name = "BLOCK", default_value = DEFAULT_TO_BLOCK)]
351 to_block: String,
352 #[command(flatten)]
354 verify: VerifyFlags,
355 #[arg(
359 long = "verify-all-interval",
360 value_name = "BLOCKS",
361 default_value_t = DEFAULT_VERIFY_ALL_INTERVAL
362 )]
363 verify_all_interval: u64,
364 #[arg(long = "one-chunk", action = ArgAction::SetTrue)]
366 stop_after_one_chunk: bool,
367 #[arg(long = "dry-run", action = ArgAction::SetTrue)]
369 dry_run: bool,
370 #[arg(long = "backup", action = ArgAction::SetTrue, overrides_with = "no_backup")]
373 backup: bool,
374 #[arg(long = "no-backup", action = ArgAction::SetTrue, overrides_with = "backup")]
376 no_backup: bool,
377 },
378 Position {
380 #[command(subcommand)]
382 command: AavePositionSub,
383 },
384}
385
386#[derive(Debug, Subcommand)]
388pub enum AavePositionSub {
389 Show {
391 #[arg(value_name = "ADDRESS")]
393 address: String,
394 #[arg(
396 long = "market",
397 value_name = "MARKET",
398 default_value = "Aave Ethereum Market"
399 )]
400 market: String,
401 },
402}
403
404#[derive(Debug, Subcommand)]
406pub enum FleetSub {
407 Posture {
409 #[command(subcommand)]
411 command: FleetPostureSub,
412 },
413}
414
415#[derive(Debug, Subcommand)]
417pub enum FleetPostureSub {
418 Show {
420 #[arg(
421 long,
422 value_name = "PATH",
423 help = "Unix domain socket path of the running bot's OperatorServer."
424 )]
425 socket: Option<String>,
426 },
427 Set {
429 #[arg(
430 long,
431 value_name = "PATH",
432 help = "Unix domain socket path of the running bot's OperatorServer."
433 )]
434 socket: Option<String>,
435 #[arg(long = "cordon-enter-events", value_name = "COUNT")]
437 cordon_enter_events: Option<u64>,
438 #[arg(long = "cordon-duty-percent", value_name = "PERCENT")]
441 cordon_duty_percent: Option<f64>,
442 #[arg(long = "cordon-enter-window-ms", value_name = "MS")]
444 cordon_enter_window_ms: Option<u64>,
445 #[arg(long = "cordon-duty-window-ms", value_name = "MS")]
447 cordon_duty_window_ms: Option<u64>,
448 #[arg(long = "cordon-exit-clean-ms", value_name = "MS")]
450 cordon_exit_clean_ms: Option<u64>,
451 #[arg(
452 long = "cordon-sim-intake-floor",
453 value_name = "COUNT|null",
454 help = "SimDriver new-lease cap while cordoned. The literal null restores half the slot cap."
455 )]
456 cordon_sim_intake_floor: Option<String>,
457 },
458}
459
460#[derive(Debug, Subcommand)]
462pub enum PathSub {
463 Add {
465 #[arg(
466 long,
467 value_name = "PATH",
468 help = "Unix domain socket path of the running bot's OperatorServer."
469 )]
470 socket: Option<String>,
471 #[arg(long = "hop", value_name = "HOP", required = true)]
474 hops: Vec<String>,
475 #[arg(long = "direction", value_enum)]
479 direction: Option<DirectionArg>,
480 },
481 Discover {
483 #[arg(
484 long,
485 value_name = "PATH",
486 help = "Unix domain socket path of the running bot's OperatorServer."
487 )]
488 socket: Option<String>,
489 #[arg(long = "bound", value_name = "COUNT")]
491 bound: Option<u64>,
492 },
493}
494
495#[derive(Debug, Clone, Copy, ValueEnum)]
497pub enum DirectionArg {
498 Zfo,
500 Ozf,
502}
503
504#[derive(Debug, Subcommand)]
507pub enum StrategySub {
508 List,
510 Show {
513 #[arg(value_enum)]
515 facet: FacetArg,
516 },
517 Activate {
521 #[arg(value_enum)]
523 facet: FacetArg,
524 #[arg(
526 long = "endpoints",
527 value_name = "URLS",
528 conflicts_with = "endpoints_default"
529 )]
530 endpoints: Option<String>,
531 #[arg(long = "endpoints-default")]
533 endpoints_default: bool,
534 },
535 Deactivate {
537 #[arg(value_enum)]
539 facet: FacetArg,
540 },
541 Set {
543 #[arg(value_enum)]
545 facet: FacetArg,
546 key: String,
548 value: String,
550 },
551 Default {
554 #[arg(value_enum)]
556 facet: FacetArg,
557 key: String,
559 },
560 Remove {
562 #[arg(value_enum)]
564 facet: FacetArg,
565 key: String,
567 },
568}
569
570#[derive(Debug, Clone, Copy, ValueEnum)]
572pub enum FacetArg {
573 Settlement,
575 #[value(name = "mevblocker_backrun")]
577 MevblockerBackrun,
578 #[value(name = "peer_backrun")]
580 PeerBackrun,
581}
582
583#[must_use]
585pub fn context<'a>(cli: &Cli, env: &'a dyn EnvVars) -> CliContext<'a> {
586 let mut ctx = CliContext::new(env);
587 if let Some(database) = &cli.database {
588 ctx = ctx.with_database(database.clone());
589 }
590 if let Some(chain_id) = &cli.chain_id {
591 ctx = ctx.with_chain_id(chain_id.clone());
592 }
593 if let Some(node_http) = &cli.node_http {
594 ctx = ctx.with_node_http(node_http.clone());
595 }
596 if let Some(node_ws) = &cli.node_ws {
597 ctx = ctx.with_node_ws(node_ws.clone());
598 }
599 if let Some(config) = &cli.config {
600 ctx = ctx.with_config(config.clone());
601 }
602 ctx
603}
604
605pub fn resolve(cli: &Cli) -> Result<Command, CliError> {
613 resolve_with_env(cli, &ProcessEnv)
614}
615
616pub fn resolve_with_env(cli: &Cli, env: &dyn EnvVars) -> Result<Command, CliError> {
627 let ctx = context(cli, env);
628 let Some(command) = &cli.command else {
629 return Err(CliError::InvalidArgument(
630 "a subcommand is required".to_string(),
631 ));
632 };
633 match command {
634 Commands::Database { command } => Ok(Command::Database(database(command))),
635 Commands::Exchange { command } => Ok(Command::Exchange(exchange(command))),
636 Commands::Pool { command } => Ok(Command::Pool(pool(command))),
637 Commands::Aave { command } => Ok(Command::Aave(aave(command, cli, &ctx)?)),
638 Commands::Fleet { command } => Ok(Command::Fleet(fleet(command)?)),
639 Commands::Path { command } => Ok(Command::Path(path(command))),
640 Commands::Strategy { command } => Ok(Command::Strategy(strategy(command))),
641 }
642}
643
644pub fn write_missing_subcommand_error() {
646 let mut command = Cli::command();
647 let usage = command.render_usage().to_string();
648 let _ = writeln!(
649 std::io::stderr().lock(),
650 "error: a subcommand is required\n\n{usage}"
651 );
652}
653
654fn database(command: &DatabaseSub) -> DatabaseCommand {
655 match command {
656 DatabaseSub::Backup => DatabaseCommand::Backup,
657 DatabaseSub::Reset { force } => DatabaseCommand::Reset { force: *force },
658 DatabaseSub::Upgrade { force } => DatabaseCommand::Upgrade { force: *force },
659 DatabaseSub::Compact => DatabaseCommand::Compact,
660 DatabaseSub::Cutover { dry_run, force } => DatabaseCommand::Cutover {
661 dry_run: *dry_run,
662 force: *force,
663 },
664 DatabaseSub::Heal { dry_run, force } => DatabaseCommand::Heal {
665 dry_run: *dry_run,
666 force: *force,
667 },
668 DatabaseSub::Inspect => DatabaseCommand::Inspect,
669 }
670}
671
672fn exchange(command: &ExchangeSub) -> ExchangeCommand {
673 match command {
674 ExchangeSub::Activate { chain, name } => ExchangeCommand::Activate {
675 chain: chain.clone(),
676 name: name.clone(),
677 },
678 ExchangeSub::Deactivate { chain, name } => ExchangeCommand::Deactivate {
679 chain: chain.clone(),
680 name: name.clone(),
681 },
682 ExchangeSub::List { chain } => ExchangeCommand::List {
683 chain: chain.clone(),
684 },
685 }
686}
687
688fn pool(command: &PoolSub) -> PoolCommand {
689 match command {
690 PoolSub::Update {
691 chunk_size,
692 to_block,
693 verify,
694 verify_all_interval,
695 } => PoolCommand::Update {
696 chunk_size: *chunk_size,
697 to_block: to_block.clone(),
698 verify_chunk: verify.chunk_gate(),
699 verify_all: verify.all_gate(),
700 verify_all_interval: *verify_all_interval,
701 },
702 PoolSub::Verify {
703 rpc_url,
704 pool_chain_id,
705 block_number,
706 pool,
707 family,
708 pool_manager,
709 } => PoolCommand::Verify {
710 rpc_url: rpc_url.clone(),
711 chain_id: *pool_chain_id,
712 block_number: *block_number,
713 pool: pool.clone(),
714 family: match family {
715 FamilyArg::V3 => PoolFamily::V3,
716 FamilyArg::V4 => PoolFamily::V4,
717 },
718 pool_manager: pool_manager.clone(),
719 },
720 }
721}
722
723fn aave(command: &AaveSub, cli: &Cli, ctx: &CliContext<'_>) -> Result<AaveCommand, CliError> {
724 match command {
725 AaveSub::Activate => Ok(AaveCommand::Activate {
726 chain_id: chain_or_default(cli, ctx, 1)?,
727 }),
728 AaveSub::Deactivate { market_name } => Ok(AaveCommand::Deactivate {
729 chain_id: chain_or_default(cli, ctx, 1)?,
730 market_name: market_name.clone(),
731 }),
732 AaveSub::Update {
733 chunk_size,
734 to_block,
735 verify,
736 verify_all_interval,
737 stop_after_one_chunk,
738 dry_run,
739 backup,
740 no_backup,
741 } => Ok(AaveCommand::Update {
742 chunk_size: *chunk_size,
743 to_block: to_block.clone(),
744 verify_chunk: verify.chunk_gate(),
745 verify_all: verify.all_gate(),
746 verify_all_interval: *verify_all_interval,
747 stop_after_one_chunk: *stop_after_one_chunk,
748 dry_run: *dry_run,
749 enable_backup: *backup && !*no_backup,
750 }),
751 AaveSub::Position { command } => match command {
752 AavePositionSub::Show { address, market } => Ok(AaveCommand::PositionShow {
753 address: address.clone(),
754 market: market.clone(),
755 chain_id: chain_or_default(cli, ctx, 1)?,
756 }),
757 },
758 }
759}
760
761fn fleet(command: &FleetSub) -> Result<FleetCommand, CliError> {
762 match command {
763 FleetSub::Posture { command } => match command {
764 FleetPostureSub::Show { socket } => Ok(FleetCommand::PostureShow {
765 socket: socket.clone(),
766 }),
767 FleetPostureSub::Set {
768 socket,
769 cordon_enter_events,
770 cordon_duty_percent,
771 cordon_enter_window_ms,
772 cordon_duty_window_ms,
773 cordon_exit_clean_ms,
774 cordon_sim_intake_floor,
775 } => {
776 let mut patch = Vec::new();
777 if let Some(value) = cordon_enter_events {
778 patch.push(PosturePatchEntry::int("cordon_enter_events", *value));
779 }
780 if let Some(value) = cordon_duty_percent {
781 patch.push(PosturePatchEntry::float("cordon_duty_percent", *value));
782 }
783 if let Some(value) = cordon_enter_window_ms {
784 patch.push(PosturePatchEntry::int("cordon_enter_window_ms", *value));
785 }
786 if let Some(value) = cordon_duty_window_ms {
787 patch.push(PosturePatchEntry::int("cordon_duty_window_ms", *value));
788 }
789 if let Some(value) = cordon_exit_clean_ms {
790 patch.push(PosturePatchEntry::int("cordon_exit_clean_ms", *value));
791 }
792 if let Some(value) = cordon_sim_intake_floor {
793 patch.push(PosturePatchEntry::new(
794 "cordon_sim_intake_floor",
795 degenbot_cli_core::parse_sim_intake_floor(value)?,
796 ));
797 }
798 Ok(FleetCommand::PostureSet {
799 socket: socket.clone(),
800 patch,
801 })
802 }
803 },
804 }
805}
806
807fn path(command: &PathSub) -> PathCommand {
808 match command {
809 PathSub::Add {
810 socket,
811 hops,
812 direction,
813 } => PathCommand::Add {
814 socket: socket.clone(),
815 hops: hops.clone(),
816 direction: direction.map(|direction| match direction {
817 DirectionArg::Zfo => PathDirection::Zfo,
818 DirectionArg::Ozf => PathDirection::Ozf,
819 }),
820 },
821 PathSub::Discover { socket, bound } => PathCommand::Discover {
822 socket: socket.clone(),
823 bound: *bound,
824 },
825 }
826}
827
828fn strategy(command: &StrategySub) -> StrategyCommand {
829 match command {
830 StrategySub::List => StrategyCommand::List,
831 StrategySub::Show { facet } => StrategyCommand::Show {
832 facet: facet_of(*facet),
833 },
834 StrategySub::Activate {
835 facet,
836 endpoints,
837 endpoints_default,
838 } => StrategyCommand::Activate {
839 facet: facet_of(*facet),
840 endpoints: endpoints.clone(),
841 endpoints_default: *endpoints_default,
842 },
843 StrategySub::Deactivate { facet } => StrategyCommand::Deactivate {
844 facet: facet_of(*facet),
845 },
846 StrategySub::Set { facet, key, value } => StrategyCommand::Set {
847 facet: facet_of(*facet),
848 key: key.clone(),
849 value: value.clone(),
850 },
851 StrategySub::Default { facet, key } => StrategyCommand::Default {
852 facet: facet_of(*facet),
853 key: key.clone(),
854 },
855 StrategySub::Remove { facet, key } => StrategyCommand::Remove {
856 facet: facet_of(*facet),
857 key: key.clone(),
858 },
859 }
860}
861
862fn facet_of(arg: FacetArg) -> StrategyFacet {
863 match arg {
864 FacetArg::Settlement => StrategyFacet::Settlement,
865 FacetArg::MevblockerBackrun => StrategyFacet::MevblockerBackrun,
866 FacetArg::PeerBackrun => StrategyFacet::PeerBackrun,
867 }
868}
869
870fn chain_or_default(cli: &Cli, ctx: &CliContext<'_>, default: u64) -> Result<u64, CliError> {
874 let cli_layer = cli
875 .chain_id
876 .as_deref()
877 .is_some_and(|value| !value.is_empty());
878 let env_layer = ctx
879 .env()
880 .get(DEFAULT_CHAIN_ID_ENV)
881 .is_some_and(|value| !value.is_empty());
882 if cli_layer || env_layer {
883 return ctx
884 .chain_id()
885 .map(|resolved| resolved.value)
886 .map_err(CliError::from);
887 }
888 Ok(default)
889}