pcli/command/tx/replicate/
xyk.rs

1use std::io::Write;
2use std::path::PathBuf;
3
4use anyhow::{anyhow, Context};
5use dialoguer::Confirm;
6use rand_core::OsRng;
7
8use penumbra_sdk_asset::Value;
9use penumbra_sdk_dex::{lp::position::Position, DirectedUnitPair};
10use penumbra_sdk_keys::keys::AddressIndex;
11use penumbra_sdk_num::{fixpoint::U128x128, Amount};
12use penumbra_sdk_proto::view::v1::GasPricesRequest;
13use penumbra_sdk_view::{Planner, ViewClient};
14
15use crate::dex_utils;
16use crate::dex_utils::replicate::debug;
17use crate::{warning, App};
18
19#[derive(Debug, Clone, clap::Args)]
20pub struct ConstantProduct {
21    pub pair: DirectedUnitPair,
22    pub input: Value,
23
24    #[clap(short, long)]
25    pub current_price: Option<f64>,
26
27    #[clap(short, long, default_value_t = 0u32)]
28    pub fee_bps: u32,
29    /// `--yes` means all prompt interaction are skipped and agreed.
30    #[clap(short, long)]
31    pub yes: bool,
32
33    #[clap(short, long, hide(true))]
34    pub debug_file: Option<PathBuf>,
35    #[clap(long, default_value = "0", hide(true))]
36    pub source: u32,
37}
38
39impl ConstantProduct {
40    pub async fn exec(&self, app: &mut App) -> anyhow::Result<()> {
41        self.validate()?;
42        let pair = self.pair.clone();
43        let current_price =
44            super::process_price_or_fetch_spread(app, self.current_price, self.pair.clone())
45                .await?;
46
47        let positions = dex_utils::replicate::xyk::replicate(
48            &pair,
49            &self.input,
50            current_price.try_into()?,
51            self.fee_bps,
52        )?;
53
54        let (amount_start, amount_end) =
55            positions
56                .iter()
57                .fold((Amount::zero(), Amount::zero()), |acc, pos| {
58                    (
59                        acc.0
60                            + pos
61                                .reserves_for(pair.start.id())
62                                .expect("start is part of position"),
63                        acc.1
64                            + pos
65                                .reserves_for(pair.end.id())
66                                .expect("end is part of position"),
67                    )
68                });
69        let amount_start = pair.start.format_value(amount_start);
70        let amount_end = pair.end.format_value(amount_end);
71
72        warning::rmm();
73
74        if !self.yes
75            && !Confirm::new()
76                .with_prompt("In the solemn voice of Mandos, he who sets the fates of all, you hear a question,\nechoing like a whisper through the Halls of Waiting:\n\"Do you, in your heart of hearts, truly wish to proceed?\"")
77                .interact()?
78        {
79            return Ok(());
80        }
81        println!("\nso it shall be...\n\n");
82        println!(
83            "#################################################################################"
84        );
85        println!(
86            "########################### LIQUIDITY SUMMARY ###################################"
87        );
88        println!(
89            "#################################################################################"
90        );
91        println!("\nYou want to provide liquidity on the pair {}", pair);
92        println!("You will need:",);
93        println!(" -> {amount_start}{}", pair.start);
94        println!(" -> {amount_end}{}", pair.end);
95        // TODO(erwan): would be nice to print current balance?
96
97        println!("You will create the following positions:");
98        let asset_cache = app.view().assets().await?;
99        println!(
100            "{}",
101            crate::command::utils::render_positions(&asset_cache, &positions),
102        );
103
104        if let Some(debug_file) = &self.debug_file {
105            Self::write_debug_data(
106                debug_file.clone(),
107                self.pair.clone(),
108                self.input.clone(),
109                current_price,
110                positions.clone(),
111            )?;
112            return Ok(());
113        }
114
115        if !self.yes
116            && !Confirm::new()
117                .with_prompt("Do you want to open those liquidity positions on-chain?")
118                .interact()?
119        {
120            return Ok(());
121        }
122
123        let gas_prices = app
124            .view
125            .as_mut()
126            .context("view service must be initialized")?
127            .gas_prices(GasPricesRequest {})
128            .await?
129            .into_inner()
130            .gas_prices
131            .expect("gas prices must be available")
132            .try_into()?;
133
134        let mut planner = Planner::new(OsRng);
135        planner.set_gas_prices(gas_prices);
136        positions.iter().for_each(|position| {
137            planner.position_open(position.clone());
138        });
139
140        let plan = planner
141            .plan(
142                app.view
143                    .as_mut()
144                    .context("view service must be initialized")?,
145                AddressIndex::new(self.source),
146            )
147            .await?;
148        let tx_id = app.build_and_submit_transaction(plan).await?;
149        println!("posted with transaction id: {tx_id}");
150
151        Ok(())
152    }
153
154    fn validate(&self) -> anyhow::Result<()> {
155        if self.input.asset_id != self.pair.start.id() && self.input.asset_id != self.pair.end.id()
156        {
157            anyhow::bail!("you must supply liquidity with an asset that's part of the market")
158        } else if self.input.amount == 0u64.into() {
159            anyhow::bail!("the quantity of liquidity supplied must be non-zero.",)
160        } else if self.fee_bps > 5000 {
161            anyhow::bail!("the maximum fee is 5000bps (50%)")
162        } else if self.current_price.is_some()
163            && self.current_price.expect("current price is Some") <= 0.0
164        {
165            anyhow::bail!("the supplied current price must be positive")
166        } else {
167            Ok(())
168        }
169    }
170
171    pub(crate) fn write_debug_data(
172        file: PathBuf,
173        pair: DirectedUnitPair,
174        input: Value,
175        current_price: f64,
176        positions: Vec<Position>,
177    ) -> anyhow::Result<()> {
178        // Ad-hoc denom scaling for debug data:
179        let alphas = dex_utils::replicate::xyk::sample_prices(
180            current_price,
181            dex_utils::replicate::xyk::NUM_POOLS_PRECISION,
182        );
183
184        alphas
185            .iter()
186            .enumerate()
187            .for_each(|(i, alpha)| tracing::debug!(i, alpha, "sampled tick"));
188
189        let r1: f64;
190
191        {
192            let raw_r1 = input.amount.value();
193            let denom_unit = pair.start.unit_amount().value();
194            let fp_r1 = U128x128::ratio(raw_r1, denom_unit).expect("denom unit is not 0");
195            r1 = fp_r1.into();
196        }
197
198        let r2 = r1 * current_price;
199        let total_k = r1 * r2;
200        println!("Entry R1: {r1}");
201        println!("Entry R2: {r2}");
202        println!("total K: {total_k}");
203
204        let debug_positions: Vec<debug::PayoffPositionEntry> = positions
205            .iter()
206            .zip(alphas)
207            .enumerate()
208            .map(|(idx, (pos, alpha))| {
209                let payoff_entry = debug::PayoffPosition::from_position(pair.clone(), pos.clone());
210                debug::PayoffPositionEntry {
211                    payoff: payoff_entry,
212                    current_price,
213                    index: idx,
214                    pair: pair.clone(),
215                    alpha,
216                    total_k,
217                }
218            })
219            .collect();
220
221        let mut fd = std::fs::File::create(&file).map_err(|e| {
222            anyhow!(
223                "fs error opening debug file {}: {}",
224                file.to_string_lossy(),
225                e
226            )
227        })?;
228
229        let json_data = serde_json::to_string(&debug_positions)
230            .map_err(|e| anyhow!("error serializing PayoffPositionEntry: {}", e))?;
231
232        fd.write_all(json_data.as_bytes())
233            .map_err(|e| anyhow!("error writing {}: {}", file.to_string_lossy(), e))?;
234        Ok(())
235    }
236}