Skip to main content

perpl_cli/
args.rs

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    /// RPC endpoint to connect to [default: https://rpc.monad.xyz for mainnet, https://testnet-rpc.monad.xyz for testnet]
18    #[arg(long, global = true)]
19    pub rpc: Option<String>,
20
21    /// Use testnet provider and contract addresses [default: false = mainnet]
22    #[arg(long, global = true)]
23    pub testnet: bool,
24
25    /// RPC throttling (req/sec) [default: 15 for default RPC providers and
26    /// none for custom]
27    #[arg(long, global = true)]
28    pub rpc_throttle: Option<u32>,
29
30    /// Exchange smart contract address [default: mainnet/testnet smart
31    /// contracts]
32    #[arg(long, global = true)]
33    pub exchange: Option<Address>,
34
35    /// Block number to fetch state at or start tracing from [default: latest
36    /// block]
37    #[arg(long, global = true)]
38    pub block: Option<u64>,
39
40    /// Number of blocks to trace or show [default: unlimited, until terminated
41    /// by (Ctrl+C)]
42    #[arg(long, global = true)]
43    pub num_blocks: Option<u64>,
44
45    /// Account addresses or ID to snaphot/trace/show [default: all accounts for
46    /// `snapshot`/`trace`, required for `show account`]
47    #[arg(long, global = true)]
48    pub account: Vec<types::AccountAddressOrID>,
49
50    /// Perpetual ID to show state/trace for [default: all perpetuals
51    /// for `snapshot`/`trace`/`show trades`, required for `show book`]
52    #[arg(long, global = true)]
53    pub perp: Vec<types::PerpetualId>,
54
55    /// Account address or ID whose entries - traced events, resting orders,
56    /// trades - are painted on a contrasting background [default: no
57    /// highlighting]
58    #[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    /// Trace raw events from a particular block
65    Block {
66        /// Block number to trace
67        block_number: u64,
68    },
69    /// Show live state of account, perpetual order book or recent trades
70    Show {
71        #[command(subcommand)]
72        command: ShowCommands,
73    },
74    /// Take a snapshot of exchange state at a particular block height
75    Snapshot,
76    /// Take an initial snapshot, then trace all events, then print the final
77    /// state
78    Trace,
79    /// Trace raw events from a particular transaction
80    Tx {
81        /// Transaction hash to trace
82        tx_hash: TxHash,
83    },
84}
85
86#[derive(Subcommand, Debug)]
87pub enum ShowCommands {
88    /// Show account state
89    Account {
90        /// Number of most recent trades to show (0 = don't show trades)
91        #[arg(long, default_value_t = 10)]
92        num_trades: usize,
93    },
94    /// Show state of perpetual order book
95    Book {
96        #[command(flatten)]
97        book: BookArgs,
98    },
99    /// Show how the given market makers are distributed across a perpetual
100    /// order book, with their orders colour-coded and their quoting summarised
101    Mms {
102        /// Market makers to track, each an account address or ID with an
103        /// optional label: `ACCOUNT[:LABEL]`. Repeat the argument or separate
104        /// entries with commas, eg. `12:Alpha 0xabc..:Beta`
105        #[arg(value_name = "ACCOUNT[:LABEL]", required = true, value_delimiter = ',')]
106        makers: Vec<MarketMaker>,
107
108        #[command(flatten)]
109        book: BookArgs,
110    },
111    /// Show recent trades
112    Trades,
113}
114
115/// How much of an order book to render, shared by every command that draws
116/// one.
117#[derive(clap::Args, Debug)]
118pub struct BookArgs {
119    /// Number of price levels to display (0 = all)
120    #[arg(short, long, default_value_t = 10)]
121    pub depth: usize,
122
123    /// Maximum orders to show per level (0 = all)
124    #[arg(long, default_value_t = 10)]
125    pub orders_per_level: usize,
126
127    /// Whether to show expired orders
128    #[arg(long, default_value_t = false)]
129    pub show_expired: bool,
130}
131
132impl BookArgs {
133    /// Price levels to render per side, `None` for all of them.
134    pub fn depth(&self) -> Option<usize> { (self.depth > 0).then_some(self.depth) }
135
136    /// Orders to render per price level, `None` for all of them.
137    pub fn orders_per_level(&self) -> Option<usize> {
138        (self.orders_per_level > 0).then_some(self.orders_per_level)
139    }
140}
141
142/// A market maker to track, given as an account address or ID with an optional
143/// display label after a colon.
144#[derive(Clone, Debug)]
145pub struct MarketMaker {
146    /// Account the maker quotes from.
147    pub account: types::AccountAddressOrID,
148
149    /// Label to key the maker's colour by, `None` to fall back to the account
150    /// ID it resolves to.
151    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        // Neither an address nor an account ID can contain a colon, so the
159        // first one always separates the account from its label
160        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        // `show mms` shares the book rendering options with `show book`
215        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}