1use std::str::FromStr;
2
3use alloy::primitives::{Address, TxHash};
4use clap::{Parser, Subcommand};
5use perpl_sdk::types;
6
7pub(crate) const DEFAULT_MAINNET_RPC_PROVIDER: &str = "https://rpc.monad.xyz";
8pub(crate) const DEFAULT_TESTNET_RPC_PROVIDER: &str = "https://testnet-rpc.monad.xyz";
9pub(crate) const DEFAULT_RPC_THROTTLING: u32 = 15;
10
11#[derive(Parser, Debug)]
12#[command(name = "perpl-cli", version, about, long_about = None)]
13pub struct Cli {
14 #[command(subcommand)]
15 pub command: Commands,
16
17 #[arg(long, global = true)]
19 pub rpc: Option<String>,
20
21 #[arg(long, global = true)]
23 pub testnet: bool,
24
25 #[arg(long, global = true)]
28 pub rpc_throttle: Option<u32>,
29
30 #[arg(long, global = true)]
33 pub exchange: Option<Address>,
34
35 #[arg(long, global = true)]
38 pub block: Option<u64>,
39
40 #[arg(long, global = true)]
43 pub num_blocks: Option<u64>,
44
45 #[arg(long, global = true)]
48 pub account: Vec<types::AccountAddressOrID>,
49
50 #[arg(long, global = true)]
53 pub perp: Vec<types::PerpetualId>,
54
55 #[arg(long, global = true, value_name = "ADDRESS or ACCOUNT_ID")]
59 pub highlight: Option<types::AccountAddressOrID>,
60}
61
62#[derive(Subcommand, Debug)]
63pub enum Commands {
64 Block {
66 block_number: u64,
68 },
69 Show {
71 #[command(subcommand)]
72 command: ShowCommands,
73 },
74 Snapshot,
76 Trace,
79 Tx {
81 tx_hash: TxHash,
83 },
84}
85
86#[derive(Subcommand, Debug)]
87pub enum ShowCommands {
88 Account {
90 #[arg(long, default_value_t = 10)]
92 num_trades: usize,
93 },
94 Book {
96 #[command(flatten)]
97 book: BookArgs,
98 },
99 Mms {
102 #[arg(value_name = "ACCOUNT[:LABEL]", required = true, value_delimiter = ',')]
106 makers: Vec<MarketMaker>,
107
108 #[command(flatten)]
109 book: BookArgs,
110 },
111 Trades,
113}
114
115#[derive(clap::Args, Debug)]
118pub struct BookArgs {
119 #[arg(short, long, default_value_t = 10)]
121 pub depth: usize,
122
123 #[arg(long, default_value_t = 10)]
125 pub orders_per_level: usize,
126
127 #[arg(long, default_value_t = false)]
129 pub show_expired: bool,
130}
131
132impl BookArgs {
133 pub fn depth(&self) -> Option<usize> { (self.depth > 0).then_some(self.depth) }
135
136 pub fn orders_per_level(&self) -> Option<usize> {
138 (self.orders_per_level > 0).then_some(self.orders_per_level)
139 }
140}
141
142#[derive(Clone, Debug)]
145pub struct MarketMaker {
146 pub account: types::AccountAddressOrID,
148
149 pub label: Option<String>,
152}
153
154impl FromStr for MarketMaker {
155 type Err = String;
156
157 fn from_str(s: &str) -> Result<Self, Self::Err> {
158 let (account, label) = s.split_once(':').unwrap_or((s, ""));
161 let label = label.trim();
162 Ok(Self {
163 account: types::AccountAddressOrID::from_str(account.trim())
164 .map_err(|err| err.to_string())?,
165 label: (!label.is_empty()).then(|| label.to_string()),
166 })
167 }
168}
169
170#[cfg(test)]
171mod tests {
172 use clap::Parser;
173
174 use super::*;
175
176 fn maker(spec: &str) -> MarketMaker { spec.parse().expect("valid market maker") }
177
178 #[test]
179 fn parses_a_market_maker_with_and_without_a_label() {
180 let labelled = maker("4638:Alpha");
181 assert!(matches!(labelled.account, types::AccountAddressOrID::ID(4638)));
182 assert_eq!(labelled.label.as_deref(), Some("Alpha"));
183
184 let bare = maker(" 4638 ");
185 assert!(matches!(bare.account, types::AccountAddressOrID::ID(4638)));
186 assert_eq!(bare.label, None);
187
188 let address = maker("0x0000000000000000000000000000000000000001:Beta");
189 assert!(matches!(address.account, types::AccountAddressOrID::Address(_)));
190 assert_eq!(address.label.as_deref(), Some("Beta"));
191
192 assert!("not-an-account".parse::<MarketMaker>().is_err());
193 }
194
195 #[test]
196 fn accepts_market_makers_repeated_or_comma_separated() {
197 let cli = Cli::try_parse_from([
198 "perpl-cli",
199 "--perp",
200 "1",
201 "show",
202 "mms",
203 "4638:Alpha,5022",
204 "1743:Gamma",
205 ])
206 .expect("valid arguments");
207 let Commands::Show { command: ShowCommands::Mms { makers, book } } = cli.command else {
208 panic!("expected `show mms`");
209 };
210 assert_eq!(
211 makers.iter().map(|m| m.label.clone()).collect::<Vec<_>>(),
212 vec![Some("Alpha".to_string()), None, Some("Gamma".to_string())],
213 );
214 assert_eq!(book.depth(), Some(10));
216 assert_eq!(book.orders_per_level(), Some(10));
217 }
218
219 #[test]
220 fn zero_depth_renders_the_whole_book() {
221 let book = BookArgs { depth: 0, orders_per_level: 0, show_expired: false };
222 assert_eq!(book.depth(), None);
223 assert_eq!(book.orders_per_level(), None);
224 }
225}