pcli/command/tx/replicate/
linear.rs1use 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 pub pair: DirectedUnitPair,
21
22 pub input: Value,
27
28 #[clap(short, long, display_order = 100)]
32 pub lower_price: f64,
33 #[clap(short, long, display_order = 101)]
37 pub upper_price: f64,
38
39 #[clap(short, long, default_value_t = 50u32, display_order = 200)]
41 pub fee_bps: u32,
42
43 #[clap(short, long, default_value_t = 16, display_order = 300)]
45 pub num_positions: u32,
46
47 #[clap(short, long, display_order = 400)]
53 pub current_price: Option<f64>,
54
55 #[clap(long, default_value_t = false, display_order = 500)]
59 pub close_on_fill: bool,
60
61 #[clap(short, long, display_order = 501)]
63 pub yes: bool,
64
65 #[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 let step_width = (self.upper_price - self.lower_price) / (self.num_positions - 1) as f64;
185
186 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 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 let reserves = if position_price < current_price {
225 Reserves {
228 r1: Amount::zero(),
229 r2: Amount::from(asset_2_per_position as u128),
230 }
231 } else {
232 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!(¶ms);
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 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 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}