pcli/command/tx/replicate/
linear.rs

1use anyhow::Context;
2use dialoguer::Confirm;
3use rand_core::{CryptoRngCore, OsRng};
4
5use penumbra_sdk_asset::Value;
6use penumbra_sdk_dex::{
7    lp::{position::Position, Reserves},
8    DirectedUnitPair,
9};
10use penumbra_sdk_keys::keys::AddressIndex;
11use penumbra_sdk_num::Amount;
12use penumbra_sdk_proto::view::v1::GasPricesRequest;
13use penumbra_sdk_view::{Planner, ViewClient};
14
15use crate::App;
16
17#[derive(Debug, Clone, clap::Args)]
18pub struct Linear {
19    /// The pair to provide liquidity for.
20    pub pair: DirectedUnitPair,
21
22    /// The target amount of liquidity (in asset 2) to provide.
23    ///
24    /// Note that the actual amount of liquidity provided will be a mix of
25    /// asset 1 and asset 2, depending on the current price.
26    pub input: Value,
27
28    /// The lower bound of the price range.
29    ///
30    /// Prices are the amount of asset 2 required to purchase 1 unit of asset 1.
31    #[clap(short, long, display_order = 100)]
32    pub lower_price: f64,
33    /// The upper bound of the price range.
34    ///
35    /// Prices are the amount of asset 2 required to purchase 1 unit of asset 1.
36    #[clap(short, long, display_order = 101)]
37    pub upper_price: f64,
38
39    /// The percentage fee to apply to each trade, expressed in basis points.
40    #[clap(short, long, default_value_t = 50u32, display_order = 200)]
41    pub fee_bps: u32,
42
43    /// The number of positions to create.
44    #[clap(short, long, default_value_t = 16, display_order = 300)]
45    pub num_positions: u32,
46
47    /// The current price. If not provided, the current price is fetched from
48    /// the chain.
49    ///
50    /// This is used to determine which positions should be funded with asset 1
51    /// and which positions should be funded with asset 2.
52    #[clap(short, long, display_order = 400)]
53    pub current_price: Option<f64>,
54
55    /// Closes positions on fill, for executing trades on the maker side.
56    ///
57    /// Not recommended for liquidity provision
58    #[clap(long, default_value_t = false, display_order = 500)]
59    pub close_on_fill: bool,
60
61    /// `--yes` means all prompt interaction are skipped and agreed.
62    #[clap(short, long, display_order = 501)]
63    pub yes: bool,
64
65    /// The account to use to fund the LPs and store the LP tokens.
66    #[clap(long, default_value = "0", display_order = 503)]
67    pub source: u32,
68}
69
70impl Linear {
71    pub async fn exec(&self, app: &mut App) -> anyhow::Result<()> {
72        self.validate()?;
73
74        let pair = self.pair.clone();
75
76        tracing::debug!(start = ?pair.start.base());
77        tracing::debug!(end = ?pair.end.base());
78
79        let mut asset_cache = app.view().assets().await?;
80        if !asset_cache.contains_key(&pair.start.id()) {
81            asset_cache.extend(std::iter::once(pair.start.base()));
82        }
83        if !asset_cache.contains_key(&pair.end.id()) {
84            asset_cache.extend(std::iter::once(pair.end.base()));
85        }
86
87        let current_price =
88            super::process_price_or_fetch_spread(app, self.current_price, self.pair.clone())
89                .await?;
90
91        tracing::debug!(?self);
92        tracing::debug!(?current_price);
93
94        let positions = self.build_positions(OsRng, current_price, self.input);
95
96        let (amount_start, amount_end) =
97            positions
98                .iter()
99                .fold((Amount::zero(), Amount::zero()), |acc, pos| {
100                    tracing::debug!(?pos);
101                    (
102                        acc.0
103                            + pos
104                                .reserves_for(pair.start.id())
105                                .expect("start is part of position"),
106                        acc.1
107                            + pos
108                                .reserves_for(pair.end.id())
109                                .expect("end is part of position"),
110                    )
111                });
112        let amount_start = pair.start.format_value(amount_start);
113        let amount_end = pair.end.format_value(amount_end);
114
115        println!(
116            "#################################################################################"
117        );
118        println!(
119            "########################### LIQUIDITY SUMMARY ###################################"
120        );
121        println!(
122            "#################################################################################"
123        );
124        println!("\nYou want to provide liquidity on the pair {}", pair);
125        println!("You will need:",);
126        println!(" -> {amount_start}{}", pair.start);
127        println!(" -> {amount_end}{}", pair.end);
128
129        println!("You will create the following positions:");
130        println!(
131            "{}",
132            crate::command::utils::render_positions(&asset_cache, &positions),
133        );
134
135        if !self.yes
136            && !Confirm::new()
137                .with_prompt("Do you want to open those liquidity positions on-chain?")
138                .interact()?
139        {
140            return Ok(());
141        }
142
143        let gas_prices = app
144            .view
145            .as_mut()
146            .context("view service must be initialized")?
147            .gas_prices(GasPricesRequest {})
148            .await?
149            .into_inner()
150            .gas_prices
151            .expect("gas prices must be available")
152            .try_into()?;
153
154        let mut planner = Planner::new(OsRng);
155        planner.set_gas_prices(gas_prices);
156        positions.iter().for_each(|position| {
157            planner.position_open(position.clone());
158        });
159
160        let plan = planner
161            .plan(
162                app.view
163                    .as_mut()
164                    .context("view service must be initialized")?,
165                AddressIndex::new(self.source),
166            )
167            .await?;
168        let tx_id = app.build_and_submit_transaction(plan).await?;
169        println!("posted with transaction id: {tx_id}");
170
171        Ok(())
172    }
173
174    fn build_positions<R: CryptoRngCore>(
175        &self,
176        mut rng: R,
177        current_price: f64,
178        input: Value,
179    ) -> Vec<Position> {
180        // The step width is num_positions-1 because it's between the endpoints
181        // |---|---|---|---|
182        // 0   1   2   3   4
183        //   0   1   2   3
184        let step_width = (self.upper_price - self.lower_price) / (self.num_positions - 1) as f64;
185
186        // We are treating asset 2 as the numeraire and want to have an even spread
187        // of asset 2 value across all positions.
188        let total_input = input.amount.value() as f64;
189        let asset_2_per_position = total_input / self.num_positions as f64;
190
191        tracing::debug!(
192            ?current_price,
193            ?step_width,
194            ?total_input,
195            ?asset_2_per_position
196        );
197
198        let mut positions = vec![];
199
200        let dtp = self.pair.into_directed_trading_pair();
201
202        for i in 0..self.num_positions {
203            let position_price = self.lower_price + step_width * i as f64;
204
205            // Cross-multiply exponents and prices for trading function coefficients
206            //
207            // We want to write
208            // p = EndUnit * price
209            // q = StartUnit
210            // However, if EndUnit is too small, it might not round correctly after multiplying by price
211            // To handle this, conditionally apply a scaling factor if the EndUnit amount is too small.
212            let scale = if self.pair.end.unit_amount().value() < 1_000_000 {
213                1_000_000
214            } else {
215                1
216            };
217
218            let p = Amount::from(
219                ((self.pair.end.unit_amount().value() * scale) as f64 * position_price) as u128,
220            );
221            let q = self.pair.start.unit_amount() * Amount::from(scale);
222
223            // Compute reserves
224            let reserves = if position_price < current_price {
225                // If the position's price is _less_ than the current price, fund it with asset 2
226                // so the position isn't immediately arbitraged.
227                Reserves {
228                    r1: Amount::zero(),
229                    r2: Amount::from(asset_2_per_position as u128),
230                }
231            } else {
232                // If the position's price is _greater_ than the current price, fund it with
233                // an equivalent amount of asset 1 as the target per-position amount of asset 2.
234                let asset_1 = asset_2_per_position / position_price;
235                Reserves {
236                    r1: Amount::from(asset_1 as u128),
237                    r2: Amount::zero(),
238                }
239            };
240
241            let position = Position::new(&mut rng, dtp, self.fee_bps, p, q, reserves);
242
243            positions.push(position);
244        }
245
246        positions
247    }
248
249    fn validate(&self) -> anyhow::Result<()> {
250        if self.input.asset_id != self.pair.end.id() {
251            anyhow::bail!("liquidity target is specified in terms of asset 2 but provided input is for a different asset")
252        } else if self.input.amount == 0u64.into() {
253            anyhow::bail!("the quantity of liquidity supplied must be non-zero.",)
254        } else if self.fee_bps > 5000 {
255            anyhow::bail!("the maximum fee is 5000bps (50%)")
256        } else if self.current_price.is_some()
257            && self.current_price.expect("current price is Some") <= 0.0
258        {
259            anyhow::bail!("the supplied current price must be positive")
260        } else if self.lower_price >= self.upper_price {
261            anyhow::bail!("the lower price must be less than the upper price")
262        } else if self.num_positions <= 2 {
263            anyhow::bail!("the number of positions must be greater than 2")
264        } else {
265            Ok(())
266        }
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use rand::SeedableRng;
273    use rand_chacha::ChaCha20Rng;
274
275    use super::*;
276
277    #[test]
278    fn sanity_check_penumbra_sdk_gm_example() {
279        let params = Linear {
280            pair: "penumbra:gm".parse().unwrap(),
281            input: "1000gm".parse().unwrap(),
282            lower_price: 1.8,
283            upper_price: 2.2,
284            fee_bps: 50,
285            num_positions: 5,
286            current_price: Some(2.05),
287            close_on_fill: false,
288            yes: false,
289            source: 0,
290        };
291
292        let mut rng = ChaCha20Rng::seed_from_u64(12345);
293
294        let positions = params.build_positions(
295            &mut rng,
296            params.current_price.unwrap(),
297            params.input.clone(),
298        );
299
300        for position in &positions {
301            dbg!(position);
302        }
303
304        let asset_cache = penumbra_sdk_asset::asset::Cache::with_known_assets();
305
306        dbg!(&params);
307        println!(
308            "{}",
309            crate::command::utils::render_positions(&asset_cache, &positions),
310        );
311
312        for position in &positions {
313            let id = position.id();
314            let buy = position.interpret_as_buy().unwrap();
315            let sell = position.interpret_as_sell().unwrap();
316            println!("{}: BUY  {}", id, buy.format(&asset_cache).unwrap());
317            println!("{}: SELL {}", id, sell.format(&asset_cache).unwrap());
318        }
319
320        let um_id = params.pair.start.id();
321        let gm_id = params.pair.end.id();
322
323        assert_eq!(positions.len(), 5);
324        // These should be all GM
325        assert_eq!(positions[0].reserves_for(um_id).unwrap(), 0u64.into());
326        assert_eq!(positions[1].reserves_for(um_id).unwrap(), 0u64.into());
327        assert_eq!(positions[2].reserves_for(um_id).unwrap(), 0u64.into());
328        // These should be all UM
329        assert_eq!(positions[3].reserves_for(gm_id).unwrap(), 0u64.into());
330        assert_eq!(positions[4].reserves_for(gm_id).unwrap(), 0u64.into());
331    }
332}