Skip to main content

perpl_cli/
lib.rs

1mod account;
2pub mod args;
3mod block;
4mod book;
5mod highlight;
6mod mms;
7mod snapshot;
8mod trace;
9mod trades;
10mod tx;
11
12use std::time::Duration;
13
14use alloy::{
15    providers::{Provider, ProviderBuilder},
16    rpc::{client::RpcClient, types::BlockId},
17    transports::layers::{RetryBackoffLayer, ThrottleLayer},
18};
19use anyhow::Context;
20use args::Cli;
21use perpl_sdk::{Chain, abi::dex, state::SnapshotBuilder, types};
22use tokio_util::sync::CancellationToken;
23
24use crate::{
25    args::{Commands, MarketMaker, ShowCommands},
26    highlight::Highlights,
27    mms::Maker,
28};
29
30pub async fn run(cli: Cli) -> anyhow::Result<()> {
31    let chain = if cli.testnet { Chain::testnet() } else { Chain::mainnet() };
32    let (rpc, default) = cli.rpc.map(|rpc| (rpc, false)).unwrap_or(if cli.testnet {
33        (args::DEFAULT_TESTNET_RPC_PROVIDER.to_string(), true)
34    } else {
35        (args::DEFAULT_MAINNET_RPC_PROVIDER.to_string(), true)
36    });
37    let client = if default || cli.rpc_throttle.is_some() {
38        // Apply throttling with default RPC
39        RpcClient::builder()
40            .layer(ThrottleLayer::new(cli.rpc_throttle.unwrap_or(args::DEFAULT_RPC_THROTTLING)))
41            .layer(RetryBackoffLayer::new(10, 100, 200))
42            .connect(&rpc)
43            .await
44            .context("connecting to RPC")?
45    } else {
46        RpcClient::builder()
47            .layer(RetryBackoffLayer::new(10, 100, 200))
48            .connect(&rpc)
49            .await
50            .context("connecting to RPC")?
51    };
52    client.set_poll_interval(Duration::from_millis(100));
53    let provider = ProviderBuilder::new().connect_client(client);
54
55    // An empty perpetual list makes the SDK track every contract listed on the
56    // exchange, discovered on-chain
57    let chain = Chain::custom(
58        provider.get_chain_id().await?,
59        chain.collateral_token(),
60        chain.deployed_at_block(),
61        cli.exchange.unwrap_or(chain.exchange()),
62        cli.perp.clone(),
63    )
64    // Carried over: `custom` starts with no exclusions, but the base chain's
65    // apply just as much to a custom exchange address on the same network
66    .with_excluded_perpetuals(chain.excluded_perpetuals().to_vec());
67
68    let block_id = cli.block.map(BlockId::number).unwrap_or(BlockId::safe());
69
70    if !cli.perp.is_empty() {
71        let listed = perpl_sdk::state::listed_perpetuals(&chain, provider.clone(), block_id)
72            .await
73            .context("discovering listed perpetuals")?;
74        if let Some(unknown_perp) = cli.perp.iter().find(|perp_id| !listed.contains(perp_id)) {
75            // Discovery leaves the chain's excluded contracts out, so say which
76            // of the two it is
77            if chain.excluded_perpetuals().contains(unknown_perp) {
78                return Err(anyhow::anyhow!(
79                    "perpetual ID {} is excluded from indexing for this chain",
80                    unknown_perp,
81                ));
82            }
83            return Err(anyhow::anyhow!(
84                "unknown perpetual ID: {}, listed: {:?}",
85                unknown_perp,
86                listed,
87            ));
88        }
89    }
90
91    let mut builder = SnapshotBuilder::new(&chain, provider.clone());
92    if let Some(block) = cli.block {
93        builder = builder.at_block(BlockId::number(block));
94    }
95
96    if !cli.account.is_empty() {
97        builder = builder.with_accounts(cli.account.clone());
98    } else {
99        builder = builder.with_all_positions();
100    }
101
102    let builder = match &cli.command {
103        Commands::Block { block_number: _ } => None,
104        Commands::Snapshot | Commands::Trace => Some(builder),
105        Commands::Show { command } => match command {
106            ShowCommands::Account { num_trades: _ } => {
107                if cli.account.len() != 1 {
108                    return Err(anyhow::anyhow!(
109                        "exactly one account should be provided, see `--account`"
110                    ));
111                }
112                Some(builder)
113            },
114            ShowCommands::Book { .. } | ShowCommands::Mms { .. } => {
115                if cli.perp.len() != 1 {
116                    return Err(anyhow::anyhow!(
117                        "exactly one perp should be provided, see `--perp`"
118                    ));
119                }
120                Some(builder)
121            },
122            ShowCommands::Trades => None,
123        },
124        Commands::Tx { tx_hash: _ } => None,
125    };
126
127    let exchange = if let Some(builder) = builder {
128        Some(
129            builder
130                .build()
131                .await
132                .context("building exchange snapshot")?,
133        )
134    } else {
135        None
136    };
137
138    // Market makers get a colour each, and the tracked account of `--highlight`
139    // its own, reserved one - so `show mms --highlight` reads unambiguously
140    let mut highlights = Highlights::default();
141    let makers = match &cli.command {
142        Commands::Show { command: ShowCommands::Mms { makers, .. } } => {
143            resolve_makers(&chain, provider.clone(), block_id, makers, &mut highlights).await?
144        },
145        _ => vec![],
146    };
147    if let Some(account) = cli.highlight {
148        highlights.track(resolve_account_id(&chain, provider.clone(), block_id, account).await?);
149    }
150
151    let cancellation_signal = CancellationToken::new();
152    let cancellation_token = cancellation_signal.child_token();
153    tokio::spawn(async move {
154        tokio::signal::ctrl_c()
155            .await
156            .expect("failed to install CTRL+C signal handler");
157        cancellation_signal.cancel();
158    });
159
160    match &cli.command {
161        Commands::Block { block_number } => {
162            block::render(&chain, provider, *block_number, &highlights).await?
163        },
164        Commands::Snapshot => snapshot::render(exchange.unwrap()),
165        Commands::Show { command } => match command {
166            ShowCommands::Account { num_trades } => {
167                account::render(
168                    chain,
169                    provider,
170                    exchange.unwrap(),
171                    cli.num_blocks,
172                    *num_trades,
173                    cancellation_token,
174                )
175                .await?
176            },
177            ShowCommands::Book { book } => {
178                book::render(
179                    chain,
180                    provider,
181                    exchange.unwrap(),
182                    book,
183                    &highlights,
184                    cli.num_blocks,
185                    cancellation_token,
186                )
187                .await?
188            },
189            ShowCommands::Mms { makers: _, book } => {
190                mms::render(
191                    chain,
192                    provider,
193                    exchange.unwrap(),
194                    makers,
195                    highlights,
196                    book,
197                    cli.num_blocks,
198                    cancellation_token,
199                )
200                .await?
201            },
202            ShowCommands::Trades => {
203                trades::render(chain, provider, &highlights, cli.num_blocks, cancellation_token)
204                    .await?
205            },
206        },
207        Commands::Trace => {
208            trace::render(
209                chain,
210                provider,
211                exchange.unwrap(),
212                &highlights,
213                cli.num_blocks,
214                cancellation_token,
215            )
216            .await?
217        },
218        Commands::Tx { tx_hash } => tx::render(&chain, provider, *tx_hash, &highlights).await?,
219    }
220
221    Ok(())
222}
223
224/// Resolves every market maker given on the command line to the account it
225/// quotes from, assigning each a colour to be highlighted in.
226async fn resolve_makers<P: Provider + Clone>(
227    chain: &Chain,
228    provider: P,
229    block_id: BlockId,
230    makers: &[MarketMaker],
231    highlights: &mut Highlights,
232) -> anyhow::Result<Vec<Maker>> {
233    let mut resolved: Vec<Maker> = Vec::with_capacity(makers.len());
234    for maker in makers {
235        let account_id =
236            resolve_account_id(chain, provider.clone(), block_id, maker.account).await?;
237        if let Some(existing) = resolved.iter().find(|m| m.account_id == account_id) {
238            return Err(anyhow::anyhow!(
239                "market makers {} and {} are the same account #{}",
240                existing.label,
241                maker.label.as_deref().unwrap_or("<unlabelled>"),
242                account_id,
243            ));
244        }
245        highlights.add(account_id);
246        resolved.push(Maker {
247            account_id,
248            label: maker
249                .label
250                .clone()
251                .unwrap_or_else(|| format!("#{}", account_id)),
252        });
253    }
254    Ok(resolved)
255}
256
257/// Resolves an account given as an address to its exchange ID, leaving an
258/// account already given by ID alone.
259async fn resolve_account_id<P: Provider + Clone>(
260    chain: &Chain,
261    provider: P,
262    block_id: BlockId,
263    account: types::AccountAddressOrID,
264) -> anyhow::Result<types::AccountId> {
265    match account {
266        types::AccountAddressOrID::ID(id) => Ok(id),
267        types::AccountAddressOrID::Address(address) => {
268            Ok(dex::Exchange::new(chain.exchange(), provider)
269                .getAccountByAddr(address)
270                .block(block_id)
271                .call()
272                .await
273                .with_context(|| format!("resolving account address {}", address))?
274                .accountId
275                .to())
276        },
277    }
278}