diff --git a/crates/bin/pcli/src/command/tx/replicate.rs b/crates/bin/pcli/src/command/tx/replicate.rs index d55d1122b2..98669824b2 100644 --- a/crates/bin/pcli/src/command/tx/replicate.rs +++ b/crates/bin/pcli/src/command/tx/replicate.rs @@ -4,7 +4,6 @@ pub mod linear; pub mod xyk; use linear::Linear; -use penumbra_asset::Value; use penumbra_dex::DirectedUnitPair; use penumbra_proto::core::component::dex::v1::{ query_service_client::QueryServiceClient as DexQueryServiceClient, SpreadRequest, @@ -15,7 +14,8 @@ use xyk::ConstantProduct; #[derive(Debug, clap::Subcommand)] pub enum ReplicateCmd { /// Create a set of positions that attempt to replicate an xy=k (UniV2) AMM. - #[clap(visible_alias = "xyk")] + // Hidden pending further testing & review + #[clap(visible_alias = "xyk", hide = true)] ConstantProduct(ConstantProduct), /// Create a set of positions that allocate liquidity linearly across a price range. Linear(Linear), @@ -35,34 +35,74 @@ impl ReplicateCmd { } } -pub async fn get_spread( +pub async fn process_price_or_fetch_spread( app: &mut App, - pair: DirectedUnitPair, - input: Value, + user_price: Option, + directed_pair: DirectedUnitPair, ) -> anyhow::Result { - let mut client = DexQueryServiceClient::new(app.pd_channel().await?); - let spread_data = client - .spread(SpreadRequest { - trading_pair: Some(pair.into_directed_trading_pair().to_canonical().into()), - }) - .await? - .into_inner(); - - tracing::debug!( - ?spread_data, - pair = pair.to_string(), - "fetched spread for pair" - ); - - if spread_data.best_1_to_2_position.is_none() || spread_data.best_2_to_1_position.is_none() { - anyhow::bail!("couldn't find a market price for the specified assets, you can manually specify a price using --current-price ") - } + let canonical_pair = directed_pair.into_directed_trading_pair().to_canonical(); - if input.asset_id == pair.start.id() { - Ok(spread_data.approx_effective_price_1_to_2) - } else if input.asset_id == pair.end.id() { - Ok(spread_data.approx_effective_price_2_to_1) + if let Some(user_price) = user_price { + let adjusted = adjust_price_by_exponents(user_price, &directed_pair); + tracing::debug!(?user_price, ?adjusted, "adjusted price by units"); + return Ok(adjusted); } else { - anyhow::bail!("the supplied liquidity must be on the pair") + tracing::debug!("price not provided, fetching spread"); + + let mut client = DexQueryServiceClient::new(app.pd_channel().await?); + let spread_data = client + .spread(SpreadRequest { + trading_pair: Some(canonical_pair.into()), + }) + .await? + .into_inner(); + + tracing::debug!( + ?spread_data, + pair = canonical_pair.to_string(), + "fetched spread for pair" + ); + + if spread_data.best_1_to_2_position.is_none() || spread_data.best_2_to_1_position.is_none() + { + anyhow::bail!("couldn't find a market price for the specified assets, you can manually specify a price using --current-price ") + } + + // The price is the amount of asset 2 required to buy 1 unit of asset 1 + + if directed_pair.start.id() == canonical_pair.asset_1() { + Ok(spread_data.approx_effective_price_2_to_1) + } else if directed_pair.start.id() == canonical_pair.asset_2() { + Ok(spread_data.approx_effective_price_1_to_2) + } else { + anyhow::bail!("the supplied liquidity must be on the pair") + } + } +} + +fn adjust_price_by_exponents(price: f64, pair: &DirectedUnitPair) -> f64 { + let start_exponent = pair.start.exponent() as i32; + let end_exponent = pair.end.exponent() as i32; + + price * 10f64.powi(start_exponent - end_exponent) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_adjust_price_by_exponents() { + let pair1: DirectedUnitPair = "penumbra:gm".parse().unwrap(); + let pair2: DirectedUnitPair = "upenumbra:gm".parse().unwrap(); + let pair3: DirectedUnitPair = "penumbra:ugm".parse().unwrap(); + let pair4: DirectedUnitPair = "upenumbra:ugm".parse().unwrap(); + + let base_price = 1.2; + + assert_eq!(adjust_price_by_exponents(base_price, &pair1), 1.2); + assert_eq!(adjust_price_by_exponents(base_price, &pair2), 0.0000012); + assert_eq!(adjust_price_by_exponents(base_price, &pair3), 1200000.0); + assert_eq!(adjust_price_by_exponents(base_price, &pair4), 1.2); } } diff --git a/crates/bin/pcli/src/command/tx/replicate/linear.rs b/crates/bin/pcli/src/command/tx/replicate/linear.rs index 075f63e10c..90885954cb 100644 --- a/crates/bin/pcli/src/command/tx/replicate/linear.rs +++ b/crates/bin/pcli/src/command/tx/replicate/linear.rs @@ -1,41 +1,38 @@ -use std::io::Write; -use std::path::PathBuf; - -use anyhow::{anyhow, bail, Context, Result}; +use anyhow::Context; use dialoguer::Confirm; -use rand_core::OsRng; +use rand_core::{CryptoRngCore, OsRng}; use penumbra_asset::Value; -use penumbra_dex::{lp::position::Position, DirectedUnitPair}; -use penumbra_keys::keys::AddressIndex; -use penumbra_num::{fixpoint::U128x128, Amount}; -use penumbra_proto::{ - core::component::dex::v1::{ - query_service_client::QueryServiceClient as DexQueryServiceClient, SpreadRequest, - }, - view::v1::GasPricesRequest, +use penumbra_dex::{ + lp::{position::Position, Reserves}, + DirectedUnitPair, }; +use penumbra_keys::keys::AddressIndex; +use penumbra_num::Amount; +use penumbra_proto::view::v1::GasPricesRequest; use penumbra_view::{Planner, ViewClient}; -use crate::dex_utils; -use crate::dex_utils::replicate::debug; -use crate::{warning, App}; +use crate::App; #[derive(Debug, Clone, clap::Args)] pub struct Linear { /// The pair to provide liquidity for. pub pair: DirectedUnitPair, - /// The target amount of liquidity to provide. + /// The target amount of liquidity (in asset 2) to provide. /// /// Note that the actual amount of liquidity provided will be a mix of /// asset 1 and asset 2, depending on the current price. pub input: Value, /// The lower bound of the price range. + /// + /// Prices are the amount of asset 2 required to purchase 1 unit of asset 1. #[clap(short, long, display_order = 100)] pub lower_price: f64, /// The upper bound of the price range. + /// + /// Prices are the amount of asset 2 required to purchase 1 unit of asset 1. #[clap(short, long, display_order = 101)] pub upper_price: f64, @@ -73,24 +70,185 @@ pub struct Linear { impl Linear { pub async fn exec(&self, app: &mut App) -> anyhow::Result<()> { self.validate()?; + let pair = self.pair.clone(); - let current_price = match self.current_price { - Some(user_supplied_price) => user_supplied_price, - None => super::get_spread(app, self.pair.clone(), self.input).await?, - }; + tracing::debug!(start = ?pair.start.base()); + tracing::debug!(end = ?pair.end.base()); + + let mut asset_cache = app.view().assets().await?; + if !asset_cache.contains_key(&pair.start.id()) { + asset_cache.extend(std::iter::once(pair.start.base())); + } + if !asset_cache.contains_key(&pair.end.id()) { + asset_cache.extend(std::iter::once(pair.end.base())); + } + + let current_price = + super::process_price_or_fetch_spread(app, self.current_price, self.pair.clone()) + .await?; + + tracing::debug!(?self); + tracing::debug!(?current_price); + + let positions = self.build_positions(OsRng, current_price, self.input); - // - dbg!(&self); - dbg!(current_price); + let (amount_start, amount_end) = + positions + .iter() + .fold((Amount::zero(), Amount::zero()), |acc, pos| { + tracing::debug!(?pos); + ( + acc.0 + + pos + .reserves_for(pair.start.id()) + .expect("start is part of position"), + acc.1 + + pos + .reserves_for(pair.end.id()) + .expect("end is part of position"), + ) + }); + let amount_start = pair.start.format_value(amount_start); + let amount_end = pair.end.format_value(amount_end); + + println!( + "#################################################################################" + ); + println!( + "########################### LIQUIDITY SUMMARY ###################################" + ); + println!( + "#################################################################################" + ); + println!("\nYou want to provide liquidity on the pair {}", pair); + println!("You will need:",); + println!(" -> {amount_start}{}", pair.start); + println!(" -> {amount_end}{}", pair.end); + + println!("You will create the following positions:"); + println!( + "{}", + crate::command::utils::render_positions(&asset_cache, &positions), + ); + + if !self.yes + && !Confirm::new() + .with_prompt("Do you want to open those liquidity positions on-chain?") + .interact()? + { + return Ok(()); + } + + let gas_prices = app + .view + .as_mut() + .context("view service must be initialized")? + .gas_prices(GasPricesRequest {}) + .await? + .into_inner() + .gas_prices + .expect("gas prices must be available") + .try_into()?; + + let mut planner = Planner::new(OsRng); + planner.set_gas_prices(gas_prices); + positions.iter().for_each(|position| { + planner.position_open(position.clone()); + }); + + let plan = planner + .plan( + app.view + .as_mut() + .context("view service must be initialized")?, + AddressIndex::new(self.source), + ) + .await?; + let tx_id = app.build_and_submit_transaction(plan).await?; + println!("posted with transaction id: {tx_id}"); Ok(()) } + fn build_positions( + &self, + mut rng: R, + current_price: f64, + input: Value, + ) -> Vec { + // The step width is num_positions-1 because it's between the endpoints + // |---|---|---|---| + // 0 1 2 3 4 + // 0 1 2 3 + let step_width = (self.upper_price - self.lower_price) / (self.num_positions - 1) as f64; + + // We are treating asset 2 as the numeraire and want to have an even spread + // of asset 2 value across all positions. + let total_input = input.amount.value() as f64; + let asset_2_per_position = total_input / self.num_positions as f64; + + tracing::debug!( + ?current_price, + ?step_width, + ?total_input, + ?asset_2_per_position + ); + + let mut positions = vec![]; + + let dtp = self.pair.into_directed_trading_pair(); + + for i in 0..self.num_positions { + let position_price = self.lower_price + step_width * i as f64; + + // Cross-multiply exponents and prices for trading function coefficients + // + // We want to write + // p = EndUnit * price + // q = StartUnit + // However, if EndUnit is too small, it might not round correctly after multiplying by price + // To handle this, conditionally apply a scaling factor if the EndUnit amount is too small. + let scale = if self.pair.end.unit_amount().value() < 1_000_000 { + 1_000_000 + } else { + 1 + }; + + let p = Amount::from( + ((self.pair.end.unit_amount().value() * scale) as f64 * position_price) as u128, + ); + let q = self.pair.start.unit_amount() * Amount::from(scale); + + // Compute reserves + let reserves = if position_price < current_price { + // If the position's price is _less_ than the current price, fund it with asset 2 + // so the position isn't immediately arbitraged. + Reserves { + r1: Amount::zero(), + r2: Amount::from(asset_2_per_position as u128), + } + } else { + // If the position's price is _greater_ than the current price, fund it with + // an equivalent amount of asset 1 as the target per-position amount of asset 2. + let asset_1 = asset_2_per_position / position_price; + Reserves { + r1: Amount::from(asset_1 as u128), + r2: Amount::zero(), + } + }; + + let position = Position::new(&mut rng, dtp, self.fee_bps, p, q, reserves); + + positions.push(position); + } + + positions + } + fn validate(&self) -> anyhow::Result<()> { - if self.input.asset_id != self.pair.start.id() && self.input.asset_id != self.pair.end.id() - { - anyhow::bail!("you must supply liquidity with an asset that's part of the market") + if self.input.asset_id != self.pair.end.id() { + anyhow::bail!("liquidity target is specified in terms of asset 2 but provided input is for a different asset") } else if self.input.amount == 0u64.into() { anyhow::bail!("the quantity of liquidity supplied must be non-zero.",) } else if self.fee_bps > 5000 { @@ -108,3 +266,67 @@ impl Linear { } } } + +#[cfg(test)] +mod tests { + use rand::SeedableRng; + use rand_chacha::ChaCha20Rng; + + use super::*; + + #[test] + fn sanity_check_penumbra_gm_example() { + let params = Linear { + pair: "penumbra:gm".parse().unwrap(), + input: "1000gm".parse().unwrap(), + lower_price: 1.8, + upper_price: 2.2, + fee_bps: 50, + num_positions: 5, + current_price: Some(2.05), + close_on_fill: false, + yes: false, + source: 0, + }; + + let mut rng = ChaCha20Rng::seed_from_u64(12345); + + let positions = params.build_positions( + &mut rng, + params.current_price.unwrap(), + params.input.clone(), + ); + + for position in &positions { + dbg!(position); + } + + let asset_cache = penumbra_asset::asset::Cache::with_known_assets(); + + dbg!(¶ms); + println!( + "{}", + crate::command::utils::render_positions(&asset_cache, &positions), + ); + + for position in &positions { + let id = position.id(); + let buy = position.interpret_as_buy().unwrap(); + let sell = position.interpret_as_sell().unwrap(); + println!("{}: BUY {}", id, buy.format(&asset_cache).unwrap()); + println!("{}: SELL {}", id, sell.format(&asset_cache).unwrap()); + } + + let um_id = params.pair.start.id(); + let gm_id = params.pair.end.id(); + + assert_eq!(positions.len(), 5); + // These should be all GM + assert_eq!(positions[0].reserves_for(um_id).unwrap(), 0u64.into()); + assert_eq!(positions[1].reserves_for(um_id).unwrap(), 0u64.into()); + assert_eq!(positions[2].reserves_for(um_id).unwrap(), 0u64.into()); + // These should be all UM + assert_eq!(positions[3].reserves_for(gm_id).unwrap(), 0u64.into()); + assert_eq!(positions[4].reserves_for(gm_id).unwrap(), 0u64.into()); + } +} diff --git a/crates/bin/pcli/src/command/tx/replicate/xyk.rs b/crates/bin/pcli/src/command/tx/replicate/xyk.rs index 58ea6f6a2d..23d0508925 100644 --- a/crates/bin/pcli/src/command/tx/replicate/xyk.rs +++ b/crates/bin/pcli/src/command/tx/replicate/xyk.rs @@ -40,10 +40,9 @@ impl ConstantProduct { pub async fn exec(&self, app: &mut App) -> anyhow::Result<()> { self.validate()?; let pair = self.pair.clone(); - let current_price = match self.current_price { - Some(user_supplied_price) => user_supplied_price, - None => super::get_spread(app, self.pair.clone(), self.input).await?, - }; + let current_price = + super::process_price_or_fetch_spread(app, self.current_price, self.pair.clone()) + .await?; let positions = dex_utils::replicate::xyk::replicate( &pair, @@ -95,7 +94,7 @@ impl ConstantProduct { println!(" -> {amount_end}{}", pair.end); // TODO(erwan): would be nice to print current balance? - println!("You will create the following pools:"); + println!("You will create the following positions:"); let asset_cache = app.view().assets().await?; println!( "{}",