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, DEFAULT_CHUNK_SIZE,
42 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
80#[derive(Debug, Subcommand)]
82pub enum Commands {
83 Database {
85 #[command(subcommand)]
87 command: DatabaseSub,
88 },
89 Exchange {
91 #[command(subcommand)]
93 command: ExchangeSub,
94 },
95 Pool {
97 #[command(subcommand)]
99 command: PoolSub,
100 },
101 Aave {
103 #[command(subcommand)]
105 command: AaveSub,
106 },
107 Fleet {
109 #[command(subcommand)]
111 command: FleetSub,
112 },
113 Path {
115 #[command(subcommand)]
117 command: PathSub,
118 },
119}
120
121#[derive(Debug, Subcommand)]
123pub enum DatabaseSub {
124 Backup,
126 Reset {
128 #[arg(long)]
130 force: bool,
131 },
132 Upgrade {
135 #[arg(long)]
137 force: bool,
138 },
139 Compact,
141 Cutover {
143 #[arg(long)]
145 dry_run: bool,
146 #[arg(long)]
148 force: bool,
149 },
150 Heal {
153 #[arg(long)]
155 dry_run: bool,
156 #[arg(long)]
158 force: bool,
159 },
160 Inspect,
162}
163
164#[derive(Debug, Subcommand)]
166pub enum ExchangeSub {
167 Activate {
170 #[arg(long, value_name = "CHAIN")]
173 chain: String,
174 #[arg(long, value_name = "NAME")]
177 name: String,
178 },
179 Deactivate {
182 #[arg(long, value_name = "CHAIN")]
185 chain: String,
186 #[arg(long, value_name = "NAME")]
188 name: String,
189 },
190}
191
192#[derive(Debug, Subcommand)]
194pub enum PoolSub {
195 Update {
197 #[arg(long = "chunk", value_name = "BLOCKS", default_value_t = DEFAULT_CHUNK_SIZE)]
200 chunk_size: u64,
201 #[arg(long = "to-block", value_name = "BLOCK", default_value = DEFAULT_TO_BLOCK)]
207 to_block: String,
208 #[command(flatten)]
210 verify: VerifyFlags,
211 #[arg(
215 long = "verify-all-interval",
216 value_name = "BLOCKS",
217 default_value_t = DEFAULT_VERIFY_ALL_INTERVAL
218 )]
219 verify_all_interval: u64,
220 },
221 Verify {
223 #[arg(long = "rpc-url", value_name = "URL", required = true)]
225 rpc_url: String,
226 #[arg(long = "chain", value_name = "CHAIN_ID", required = true)]
230 pool_chain_id: i64,
231 #[arg(long = "block", value_name = "BLOCK", required = true)]
233 block_number: u64,
234 #[arg(
235 long = "pool",
236 value_name = "POOL",
237 required = true,
238 help = "The pool to verify. V3: the pool contract address. V4: the PoolId (pool_hash, bytes32 hex 0x…)."
239 )]
240 pool: String,
241 #[arg(
242 long = "family",
243 value_enum,
244 required = true,
245 help = "The pool family (selects ticks()/tickBitmap() vs PoolManager extsload)."
246 )]
247 family: FamilyArg,
248 #[arg(
249 long = "pool-manager",
250 value_name = "ADDRESS",
251 help = "(V4 only) The deployed V4 PoolManager singleton address (the V4 exchange's factory). Required for --family v4."
252 )]
253 pool_manager: Option<String>,
254 },
255}
256
257#[derive(Debug, Clone, Args)]
259#[expect(
260 clippy::struct_excessive_bools,
261 reason = "the two on/off gate pairs are argv vocabulary, not state: each pair is two mutually-overriding flags"
262)]
263pub struct VerifyFlags {
264 #[arg(long = "verify-chunk", action = ArgAction::SetTrue, overrides_with = "no_verify_chunk")]
268 pub verify_chunk: bool,
269 #[arg(long = "no-verify-chunk", action = ArgAction::SetTrue, overrides_with = "verify_chunk")]
271 pub no_verify_chunk: bool,
272 #[arg(long = "verify-all", action = ArgAction::SetTrue, overrides_with = "no_verify_all")]
276 pub verify_all: bool,
277 #[arg(long = "no-verify-all", action = ArgAction::SetTrue, overrides_with = "verify_all")]
279 pub no_verify_all: bool,
280}
281
282impl VerifyFlags {
283 #[must_use]
285 pub const fn chunk_gate(&self) -> bool {
286 self.verify_chunk || !self.no_verify_chunk
287 }
288
289 #[must_use]
291 pub const fn all_gate(&self) -> bool {
292 self.verify_all && !self.no_verify_all
293 }
294}
295
296#[derive(Debug, Clone, Copy, ValueEnum)]
298pub enum FamilyArg {
299 V3,
301 V4,
303}
304
305#[derive(Debug, Subcommand)]
307pub enum AaveSub {
308 Activate,
311 Deactivate {
314 #[arg(
316 long = "name",
317 value_name = "MARKET",
318 default_value = "Aave Ethereum Market"
319 )]
320 market_name: String,
321 },
322 Update {
324 #[arg(long = "chunk", value_name = "BLOCKS", default_value_t = DEFAULT_CHUNK_SIZE)]
327 chunk_size: u64,
328 #[arg(long = "to-block", value_name = "BLOCK", default_value = DEFAULT_TO_BLOCK)]
334 to_block: String,
335 #[command(flatten)]
337 verify: VerifyFlags,
338 #[arg(
342 long = "verify-all-interval",
343 value_name = "BLOCKS",
344 default_value_t = DEFAULT_VERIFY_ALL_INTERVAL
345 )]
346 verify_all_interval: u64,
347 #[arg(long = "one-chunk", action = ArgAction::SetTrue)]
349 stop_after_one_chunk: bool,
350 #[arg(long = "dry-run", action = ArgAction::SetTrue)]
352 dry_run: bool,
353 #[arg(long = "backup", action = ArgAction::SetTrue, overrides_with = "no_backup")]
356 backup: bool,
357 #[arg(long = "no-backup", action = ArgAction::SetTrue, overrides_with = "backup")]
359 no_backup: bool,
360 },
361 Position {
363 #[command(subcommand)]
365 command: AavePositionSub,
366 },
367}
368
369#[derive(Debug, Subcommand)]
371pub enum AavePositionSub {
372 Show {
374 #[arg(value_name = "ADDRESS")]
376 address: String,
377 #[arg(
379 long = "market",
380 value_name = "MARKET",
381 default_value = "Aave Ethereum Market"
382 )]
383 market: String,
384 },
385}
386
387#[derive(Debug, Subcommand)]
389pub enum FleetSub {
390 Posture {
392 #[command(subcommand)]
394 command: FleetPostureSub,
395 },
396}
397
398#[derive(Debug, Subcommand)]
400pub enum FleetPostureSub {
401 Show {
403 #[arg(
404 long,
405 value_name = "PATH",
406 help = "Unix domain socket path of the running bot's OperatorServer."
407 )]
408 socket: Option<String>,
409 },
410 Set {
412 #[arg(
413 long,
414 value_name = "PATH",
415 help = "Unix domain socket path of the running bot's OperatorServer."
416 )]
417 socket: Option<String>,
418 #[arg(long = "cordon-enter-events", value_name = "COUNT")]
420 cordon_enter_events: Option<u64>,
421 #[arg(long = "cordon-duty-percent", value_name = "PERCENT")]
424 cordon_duty_percent: Option<f64>,
425 #[arg(long = "cordon-enter-window-ms", value_name = "MS")]
427 cordon_enter_window_ms: Option<u64>,
428 #[arg(long = "cordon-duty-window-ms", value_name = "MS")]
430 cordon_duty_window_ms: Option<u64>,
431 #[arg(long = "cordon-exit-clean-ms", value_name = "MS")]
433 cordon_exit_clean_ms: Option<u64>,
434 #[arg(
435 long = "cordon-sim-intake-floor",
436 value_name = "COUNT|null",
437 help = "SimDriver new-lease cap while cordoned. The literal null restores half the slot cap."
438 )]
439 cordon_sim_intake_floor: Option<String>,
440 },
441}
442
443#[derive(Debug, Subcommand)]
445pub enum PathSub {
446 Add {
448 #[arg(
449 long,
450 value_name = "PATH",
451 help = "Unix domain socket path of the running bot's OperatorServer."
452 )]
453 socket: Option<String>,
454 #[arg(long = "hop", value_name = "HOP", required = true)]
457 hops: Vec<String>,
458 #[arg(long = "direction", value_enum)]
462 direction: Option<DirectionArg>,
463 },
464 Discover {
466 #[arg(
467 long,
468 value_name = "PATH",
469 help = "Unix domain socket path of the running bot's OperatorServer."
470 )]
471 socket: Option<String>,
472 #[arg(long = "bound", value_name = "COUNT")]
474 bound: Option<u64>,
475 },
476}
477
478#[derive(Debug, Clone, Copy, ValueEnum)]
480pub enum DirectionArg {
481 Zfo,
483 Ozf,
485}
486
487#[must_use]
489pub fn context<'a>(cli: &Cli, env: &'a dyn EnvVars) -> CliContext<'a> {
490 let mut ctx = CliContext::new(env);
491 if let Some(database) = &cli.database {
492 ctx = ctx.with_database(database.clone());
493 }
494 if let Some(chain_id) = &cli.chain_id {
495 ctx = ctx.with_chain_id(chain_id.clone());
496 }
497 if let Some(node_http) = &cli.node_http {
498 ctx = ctx.with_node_http(node_http.clone());
499 }
500 if let Some(node_ws) = &cli.node_ws {
501 ctx = ctx.with_node_ws(node_ws.clone());
502 }
503 ctx
504}
505
506pub fn resolve(cli: &Cli) -> Result<Command, CliError> {
514 resolve_with_env(cli, &ProcessEnv)
515}
516
517pub fn resolve_with_env(cli: &Cli, env: &dyn EnvVars) -> Result<Command, CliError> {
528 let ctx = context(cli, env);
529 let Some(command) = &cli.command else {
530 return Err(CliError::InvalidArgument(
531 "a subcommand is required".to_string(),
532 ));
533 };
534 match command {
535 Commands::Database { command } => Ok(Command::Database(database(command))),
536 Commands::Exchange { command } => Ok(Command::Exchange(exchange(command))),
537 Commands::Pool { command } => Ok(Command::Pool(pool(command))),
538 Commands::Aave { command } => Ok(Command::Aave(aave(command, cli, &ctx)?)),
539 Commands::Fleet { command } => Ok(Command::Fleet(fleet(command)?)),
540 Commands::Path { command } => Ok(Command::Path(path(command))),
541 }
542}
543
544pub fn write_missing_subcommand_error() {
546 let mut command = Cli::command();
547 let usage = command.render_usage().to_string();
548 let _ = writeln!(
549 std::io::stderr().lock(),
550 "error: a subcommand is required\n\n{usage}"
551 );
552}
553
554fn database(command: &DatabaseSub) -> DatabaseCommand {
555 match command {
556 DatabaseSub::Backup => DatabaseCommand::Backup,
557 DatabaseSub::Reset { force } => DatabaseCommand::Reset { force: *force },
558 DatabaseSub::Upgrade { force } => DatabaseCommand::Upgrade { force: *force },
559 DatabaseSub::Compact => DatabaseCommand::Compact,
560 DatabaseSub::Cutover { dry_run, force } => DatabaseCommand::Cutover {
561 dry_run: *dry_run,
562 force: *force,
563 },
564 DatabaseSub::Heal { dry_run, force } => DatabaseCommand::Heal {
565 dry_run: *dry_run,
566 force: *force,
567 },
568 DatabaseSub::Inspect => DatabaseCommand::Inspect,
569 }
570}
571
572fn exchange(command: &ExchangeSub) -> ExchangeCommand {
573 match command {
574 ExchangeSub::Activate { chain, name } => ExchangeCommand::Activate {
575 chain: chain.clone(),
576 name: name.clone(),
577 },
578 ExchangeSub::Deactivate { chain, name } => ExchangeCommand::Deactivate {
579 chain: chain.clone(),
580 name: name.clone(),
581 },
582 }
583}
584
585fn pool(command: &PoolSub) -> PoolCommand {
586 match command {
587 PoolSub::Update {
588 chunk_size,
589 to_block,
590 verify,
591 verify_all_interval,
592 } => PoolCommand::Update {
593 chunk_size: *chunk_size,
594 to_block: to_block.clone(),
595 verify_chunk: verify.chunk_gate(),
596 verify_all: verify.all_gate(),
597 verify_all_interval: *verify_all_interval,
598 },
599 PoolSub::Verify {
600 rpc_url,
601 pool_chain_id,
602 block_number,
603 pool,
604 family,
605 pool_manager,
606 } => PoolCommand::Verify {
607 rpc_url: rpc_url.clone(),
608 chain_id: *pool_chain_id,
609 block_number: *block_number,
610 pool: pool.clone(),
611 family: match family {
612 FamilyArg::V3 => PoolFamily::V3,
613 FamilyArg::V4 => PoolFamily::V4,
614 },
615 pool_manager: pool_manager.clone(),
616 },
617 }
618}
619
620fn aave(command: &AaveSub, cli: &Cli, ctx: &CliContext<'_>) -> Result<AaveCommand, CliError> {
621 match command {
622 AaveSub::Activate => Ok(AaveCommand::Activate {
623 chain_id: chain_or_default(cli, ctx, 1)?,
624 }),
625 AaveSub::Deactivate { market_name } => Ok(AaveCommand::Deactivate {
626 chain_id: chain_or_default(cli, ctx, 1)?,
627 market_name: market_name.clone(),
628 }),
629 AaveSub::Update {
630 chunk_size,
631 to_block,
632 verify,
633 verify_all_interval,
634 stop_after_one_chunk,
635 dry_run,
636 backup,
637 no_backup,
638 } => Ok(AaveCommand::Update {
639 chunk_size: *chunk_size,
640 to_block: to_block.clone(),
641 verify_chunk: verify.chunk_gate(),
642 verify_all: verify.all_gate(),
643 verify_all_interval: *verify_all_interval,
644 stop_after_one_chunk: *stop_after_one_chunk,
645 dry_run: *dry_run,
646 enable_backup: *backup && !*no_backup,
647 }),
648 AaveSub::Position { command } => match command {
649 AavePositionSub::Show { address, market } => Ok(AaveCommand::PositionShow {
650 address: address.clone(),
651 market: market.clone(),
652 chain_id: chain_or_default(cli, ctx, 1)?,
653 }),
654 },
655 }
656}
657
658fn fleet(command: &FleetSub) -> Result<FleetCommand, CliError> {
659 match command {
660 FleetSub::Posture { command } => match command {
661 FleetPostureSub::Show { socket } => Ok(FleetCommand::PostureShow {
662 socket: socket.clone(),
663 }),
664 FleetPostureSub::Set {
665 socket,
666 cordon_enter_events,
667 cordon_duty_percent,
668 cordon_enter_window_ms,
669 cordon_duty_window_ms,
670 cordon_exit_clean_ms,
671 cordon_sim_intake_floor,
672 } => {
673 let mut patch = Vec::new();
674 if let Some(value) = cordon_enter_events {
675 patch.push(PosturePatchEntry::int("cordon_enter_events", *value));
676 }
677 if let Some(value) = cordon_duty_percent {
678 patch.push(PosturePatchEntry::float("cordon_duty_percent", *value));
679 }
680 if let Some(value) = cordon_enter_window_ms {
681 patch.push(PosturePatchEntry::int("cordon_enter_window_ms", *value));
682 }
683 if let Some(value) = cordon_duty_window_ms {
684 patch.push(PosturePatchEntry::int("cordon_duty_window_ms", *value));
685 }
686 if let Some(value) = cordon_exit_clean_ms {
687 patch.push(PosturePatchEntry::int("cordon_exit_clean_ms", *value));
688 }
689 if let Some(value) = cordon_sim_intake_floor {
690 patch.push(PosturePatchEntry::new(
691 "cordon_sim_intake_floor",
692 degenbot_cli_core::parse_sim_intake_floor(value)?,
693 ));
694 }
695 Ok(FleetCommand::PostureSet {
696 socket: socket.clone(),
697 patch,
698 })
699 }
700 },
701 }
702}
703
704fn path(command: &PathSub) -> PathCommand {
705 match command {
706 PathSub::Add {
707 socket,
708 hops,
709 direction,
710 } => PathCommand::Add {
711 socket: socket.clone(),
712 hops: hops.clone(),
713 direction: direction.map(|direction| match direction {
714 DirectionArg::Zfo => PathDirection::Zfo,
715 DirectionArg::Ozf => PathDirection::Ozf,
716 }),
717 },
718 PathSub::Discover { socket, bound } => PathCommand::Discover {
719 socket: socket.clone(),
720 bound: *bound,
721 },
722 }
723}
724
725fn chain_or_default(cli: &Cli, ctx: &CliContext<'_>, default: u64) -> Result<u64, CliError> {
729 let cli_layer = cli
730 .chain_id
731 .as_deref()
732 .is_some_and(|value| !value.is_empty());
733 let env_layer = ctx
734 .env()
735 .get(DEFAULT_CHAIN_ID_ENV)
736 .is_some_and(|value| !value.is_empty());
737 if cli_layer || env_layer {
738 return ctx
739 .chain_id()
740 .map(|resolved| resolved.value)
741 .map_err(CliError::from);
742 }
743 Ok(default)
744}