1use anyhow::Context;
2use dialoguer::Confirm;
3use rand_core::{CryptoRngCore, OsRng};
45use 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};
1415use crate::App;
1617#[derive(Debug, Clone, clap::Args)]
18pub struct Linear {
19/// The pair to provide liquidity for.
20pub pair: DirectedUnitPair,
2122/// 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.
26pub input: Value,
2728/// 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)]
32pub 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)]
37pub upper_price: f64,
3839/// The percentage fee to apply to each trade, expressed in basis points.
40#[clap(short, long, default_value_t = 50u32, display_order = 200)]
41pub fee_bps: u32,
4243/// The number of positions to create.
44#[clap(short, long, default_value_t = 16, display_order = 300)]
45pub num_positions: u32,
4647/// 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)]
53pub current_price: Option<f64>,
5455/// 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)]
59pub close_on_fill: bool,
6061/// `--yes` means all prompt interaction are skipped and agreed.
62#[clap(short, long, display_order = 501)]
63pub yes: bool,
6465/// The account to use to fund the LPs and store the LP tokens.
66#[clap(long, default_value = "0", display_order = 503)]
67pub source: u32,
68}
6970impl Linear {
71pub async fn exec(&self, app: &mut App) -> anyhow::Result<()> {
72self.validate()?;
7374let pair = self.pair.clone();
7576tracing::debug!(start = ?pair.start.base());
77tracing::debug!(end = ?pair.end.base());
7879let mut asset_cache = app.view().assets().await?;
80if !asset_cache.contains_key(&pair.start.id()) {
81 asset_cache.extend(std::iter::once(pair.start.base()));
82 }
83if !asset_cache.contains_key(&pair.end.id()) {
84 asset_cache.extend(std::iter::once(pair.end.base()));
85 }
8687let current_price =
88super::process_price_or_fetch_spread(app, self.current_price, self.pair.clone())
89 .await?;
9091tracing::debug!(?self);
92tracing::debug!(?current_price);
9394let positions = self.build_positions(OsRng, current_price, self.input);
9596let (amount_start, amount_end) =
97 positions
98 .iter()
99 .fold((Amount::zero(), Amount::zero()), |acc, pos| {
100tracing::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 });
112let amount_start = pair.start.format_value(amount_start);
113let amount_end = pair.end.format_value(amount_end);
114115println!(
116"#################################################################################"
117);
118println!(
119"########################### LIQUIDITY SUMMARY ###################################"
120);
121println!(
122"#################################################################################"
123);
124println!("\nYou want to provide liquidity on the pair {}", pair);
125println!("You will need:",);
126println!(" -> {amount_start}{}", pair.start);
127println!(" -> {amount_end}{}", pair.end);
128129println!("You will create the following positions:");
130println!(
131"{}",
132crate::command::utils::render_positions(&asset_cache, &positions),
133 );
134135if !self.yes
136 && !Confirm::new()
137 .with_prompt("Do you want to open those liquidity positions on-chain?")
138 .interact()?
139{
140return Ok(());
141 }
142143let 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()?;
153154let 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 });
159160let 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?;
168let tx_id = app.build_and_submit_transaction(plan).await?;
169println!("posted with transaction id: {tx_id}");
170171Ok(())
172 }
173174fn build_positions<R: CryptoRngCore>(
175&self,
176mut 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
184let step_width = (self.upper_price - self.lower_price) / (self.num_positions - 1) as f64;
185186// We are treating asset 2 as the numeraire and want to have an even spread
187 // of asset 2 value across all positions.
188let total_input = input.amount.value() as f64;
189let asset_2_per_position = total_input / self.num_positions as f64;
190191tracing::debug!(
192?current_price,
193?step_width,
194?total_input,
195?asset_2_per_position
196 );
197198let mut positions = vec![];
199200let dtp = self.pair.into_directed_trading_pair();
201202for i in 0..self.num_positions {
203let position_price = self.lower_price + step_width * i as f64;
204205// 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.
212let scale = if self.pair.end.unit_amount().value() < 1_000_000 {
2131_000_000
214} else {
2151
216};
217218let p = Amount::from(
219 ((self.pair.end.unit_amount().value() * scale) as f64 * position_price) as u128,
220 );
221let q = self.pair.start.unit_amount() * Amount::from(scale);
222223// Compute reserves
224let 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.
227Reserves {
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.
234let asset_1 = asset_2_per_position / position_price;
235 Reserves {
236 r1: Amount::from(asset_1 as u128),
237 r2: Amount::zero(),
238 }
239 };
240241let position = Position::new(&mut rng, dtp, self.fee_bps, p, q, reserves);
242243 positions.push(position);
244 }
245246 positions
247 }
248249fn validate(&self) -> anyhow::Result<()> {
250if self.input.asset_id != self.pair.end.id() {
251anyhow::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() {
253anyhow::bail!("the quantity of liquidity supplied must be non-zero.",)
254 } else if self.fee_bps > 5000 {
255anyhow::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{
259anyhow::bail!("the supplied current price must be positive")
260 } else if self.lower_price >= self.upper_price {
261anyhow::bail!("the lower price must be less than the upper price")
262 } else if self.num_positions <= 2 {
263anyhow::bail!("the number of positions must be greater than 2")
264 } else {
265Ok(())
266 }
267 }
268}
269270#[cfg(test)]
271mod tests {
272use rand::SeedableRng;
273use rand_chacha::ChaCha20Rng;
274275use super::*;
276277#[test]
278fn sanity_check_penumbra_sdk_gm_example() {
279let 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 };
291292let mut rng = ChaCha20Rng::seed_from_u64(12345);
293294let positions = params.build_positions(
295&mut rng,
296 params.current_price.unwrap(),
297 params.input.clone(),
298 );
299300for position in &positions {
301dbg!(position);
302 }
303304let asset_cache = penumbra_sdk_asset::asset::Cache::with_known_assets();
305306dbg!(¶ms);
307println!(
308"{}",
309crate::command::utils::render_positions(&asset_cache, &positions),
310 );
311312for position in &positions {
313let id = position.id();
314let buy = position.interpret_as_buy().unwrap();
315let sell = position.interpret_as_sell().unwrap();
316println!("{}: BUY {}", id, buy.format(&asset_cache).unwrap());
317println!("{}: SELL {}", id, sell.format(&asset_cache).unwrap());
318 }
319320let um_id = params.pair.start.id();
321let gm_id = params.pair.end.id();
322323assert_eq!(positions.len(), 5);
324// These should be all GM
325assert_eq!(positions[0].reserves_for(um_id).unwrap(), 0u64.into());
326assert_eq!(positions[1].reserves_for(um_id).unwrap(), 0u64.into());
327assert_eq!(positions[2].reserves_for(um_id).unwrap(), 0u64.into());
328// These should be all UM
329assert_eq!(positions[3].reserves_for(gm_id).unwrap(), 0u64.into());
330assert_eq!(positions[4].reserves_for(gm_id).unwrap(), 0u64.into());
331 }
332}