penumbra_sdk_dex/lp/
order.rs

1use anyhow::{anyhow, Result};
2use penumbra_sdk_asset::{
3    asset::{self, Unit},
4    Value,
5};
6use penumbra_sdk_num::{fixpoint::U128x128, Amount};
7use rand_core::CryptoRngCore;
8use regex::Regex;
9
10use crate::DirectedTradingPair;
11
12use super::position::Position;
13
14/// Helper structure for constructing a [`Position`] expressing the desire to
15/// buy the `desired` value in exchange for the `offered` value.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct BuyOrder {
18    pub desired: Value,
19    pub offered: Value,
20    pub fee: u32,
21}
22
23/// Helper structure for constructing a [`Position`] expressing the desire to
24/// sell the `offered` value in exchange for the `desired` value.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct SellOrder {
27    pub offered: Value,
28    pub desired: Value,
29    pub fee: u32,
30}
31
32/// This doesn't parse the values yet, because we need to inspect their units.
33fn parse_parts(input: &str) -> Result<(&str, &str, u32)> {
34    let (trade_part, fee_part) = match input.rsplit_once('/') {
35        // In case we have a denom with a slash, and no fee
36        Some((trade_part, fee_part)) if fee_part.ends_with("bps") => (trade_part, fee_part),
37        _ => (input, "0bps"),
38    };
39
40    let Some((val1, val2)) = trade_part.split_once('@') else {
41        anyhow::bail!("could not parse trade string {}", input);
42    };
43
44    let fee = match fee_part.strip_suffix("bps") {
45        Some(fee) => fee.parse::<u32>()?,
46        None => anyhow::bail!("could not parse fee string {}", fee_part),
47    };
48
49    Ok((val1, val2, fee))
50}
51
52fn extract_unit(input: &str) -> Result<Unit> {
53    let unit_re = Regex::new(r"[0-9.]+([^0-9.].*+)$")?;
54    if let Some(captures) = unit_re.captures(input) {
55        let unit = captures.get(1).expect("matched regex").as_str();
56        Ok(asset::REGISTRY.parse_unit(unit))
57    } else {
58        Err(anyhow!("could not extract unit from {}", input))
59    }
60}
61
62impl BuyOrder {
63    /// Eventually we'll need to plumb in an asset::Cache so this isn't FromStr
64    pub fn parse_str(input: &str) -> Result<Self> {
65        let (desired_str, price_str, fee) = parse_parts(input)?;
66
67        let desired_unit = extract_unit(desired_str)?;
68        let desired = desired_str.parse::<Value>()?;
69        let price = price_str.parse::<Value>()?;
70
71        // In, e.g., 100mpenumbra@1.2gm, we're expressing the desire to:
72        // - buy 100_000 upenumbra (absolute value)
73        // - at a price of 1.2 gm per mpenumbra
74        // So, our offered amount is 1.2 gm * 100 = 120 gm, or
75        // 1_200_000 ugm * (100_000 upenumbra / 1_000 (mpenumbra per upenumbra)).
76
77        let price_amount = U128x128::from(price.amount); // e.g., 1_200_000
78        let desired_amount = U128x128::from(desired.amount); // e.g., 100_000
79        let desired_unit_amount = U128x128::from(desired_unit.unit_amount()); // e.g., 1_000
80
81        let offered_amount = ((price_amount * desired_amount) / desired_unit_amount)?
82            .round_up()?
83            .try_into()
84            .expect("rounded to integer");
85
86        let offered = Value {
87            amount: offered_amount,
88            asset_id: price.asset_id,
89        };
90
91        Ok(BuyOrder {
92            desired,
93            offered,
94            fee,
95        })
96    }
97
98    /// Returns a formatted representation of the price component.  This is
99    /// returned as a string because it implicitly depends on the unit of the
100    /// offered asset, so shouldn't be used for computation; it's split out for
101    /// use in tables.
102    ///
103    /// Errors if the assets in `self` aren't in `cache`.
104    pub fn price_str(&self, cache: &asset::Cache) -> Result<String> {
105        let desired_unit = cache
106            .get(&self.desired.asset_id)
107            .map(|d| d.default_unit())
108            .ok_or_else(|| anyhow!("unknown asset {}", self.desired.asset_id))?;
109
110        // When parsing, we have
111        // offered_amount = ceil(price_amount * desired_amount / desired_unit_amount)
112        // We want to compute price_amount
113        //   ignoring rounding, this is
114        //   price_amount = offered_amount * desired_unit_amount / desired_amount
115        let offered_amount = U128x128::from(self.offered.amount);
116        let desired_amount = U128x128::from(self.desired.amount);
117        let desired_unit_amount = U128x128::from(desired_unit.unit_amount());
118
119        let price_amount: Amount = ((offered_amount * desired_unit_amount) / desired_amount)?
120            // TODO: Is this the correct rounding behavior? Should we expect this to round-trip exactly?
121            .round_up()?
122            .try_into()
123            .expect("rounded to integer");
124
125        let price_str = Value {
126            amount: price_amount,
127            asset_id: self.offered.asset_id,
128        }
129        .format(cache);
130
131        Ok(price_str)
132    }
133
134    /// Formats this `BuyOrder` as a string.
135    pub fn format(&self, cache: &asset::Cache) -> Result<String> {
136        let price_str = self.price_str(cache)?;
137        let desired_str = self.desired.format(cache);
138
139        if self.fee != 0 {
140            Ok(format!("{}@{}/{}bps", desired_str, price_str, self.fee))
141        } else {
142            Ok(format!("{}@{}", desired_str, price_str))
143        }
144    }
145}
146
147impl SellOrder {
148    /// Eventually we'll need to plumb in an asset::Cache so this isn't FromStr
149    pub fn parse_str(input: &str) -> Result<Self> {
150        let (offered_str, price_str, fee) = parse_parts(input)?;
151
152        let offered_unit = extract_unit(offered_str)?;
153        let offered = offered_str.parse::<Value>()?;
154        let price = price_str.parse::<Value>()?;
155
156        // In, e.g., 100mpenumbra@1.2gm, we're expressing the desire to:
157        // - sell 100_000 upenumbra (absolute value)
158        // - at a price of 1.2 gm per mpenumbra
159        // So, our desired amount is 1.2 gm * 100 = 120 gm, or
160        // 1_200_000 ugm * (100_000 upenumbra / 1_000 (mpenumbra per upenumbra)).
161
162        let price_amount = U128x128::from(price.amount); // e.g., 1_200_000
163        let offered_amount = U128x128::from(offered.amount); // e.g., 100_000
164        let offered_unit_amount = U128x128::from(offered_unit.unit_amount()); // e.g., 1_000
165
166        let desired_amount = ((price_amount * offered_amount) / offered_unit_amount)?
167            .round_up()?
168            .try_into()
169            .expect("rounded to integer");
170
171        let desired = Value {
172            amount: desired_amount,
173            asset_id: price.asset_id,
174        };
175
176        Ok(SellOrder {
177            offered,
178            desired,
179            fee,
180        })
181    }
182
183    /// Returns a formatted representation of the price component.  This is
184    /// returned as a string because it implicitly depends on the unit of the
185    /// offered asset, so shouldn't be used for computation; it's split out for
186    /// use in tables.
187    ///
188    /// Errors if the assets in `self` aren't in `cache`.
189    pub fn price_str(&self, cache: &asset::Cache) -> Result<String> {
190        let offered_unit = cache
191            .get(&self.offered.asset_id)
192            .map(|d| d.default_unit())
193            .ok_or_else(|| anyhow!("unknown asset {}", self.offered.asset_id))?;
194
195        // When parsing, we have
196        // desired_amount = ceil(price_amount * offered_amount / offered_unit_amount)
197        // We want to compute price_amount
198        //   ignoring rounding, this is
199        //   price_amount = desired_amount * offered_unit_amount / offered_amount
200        let offered_amount = U128x128::from(self.offered.amount);
201        let desired_amount = U128x128::from(self.desired.amount);
202        let offered_unit_amount = U128x128::from(offered_unit.unit_amount());
203
204        if offered_amount == 0u64.into() {
205            return Ok("∞".to_string());
206        }
207
208        let price_amount: Amount = ((desired_amount * offered_unit_amount) / offered_amount)?
209            // TODO: Is this the correct rounding behavior? Should we expect this to round-trip exactly?
210            .round_up()?
211            .try_into()
212            .expect("rounded to integer");
213
214        let price_str = Value {
215            amount: price_amount,
216            asset_id: self.desired.asset_id,
217        }
218        .format(cache);
219
220        Ok(price_str)
221    }
222
223    /// Formats this `SellOrder` as a string.
224    pub fn format(&self, cache: &asset::Cache) -> Result<String> {
225        let price_str = self.price_str(cache)?;
226        let offered_str = self.offered.format(cache);
227
228        if self.fee != 0 {
229            Ok(format!("{}@{}/{}bps", offered_str, price_str, self.fee))
230        } else {
231            Ok(format!("{}@{}", offered_str, price_str))
232        }
233    }
234}
235
236fn into_position_inner<R: CryptoRngCore>(
237    offered: Value,
238    desired: Value,
239    fee: u32,
240    rng: R,
241) -> Position {
242    // We want to compute `p` and `q` that interpolate between two reserves states:
243    // (r1, r2) = (offered, 0) ; k = r1 * p + r2 * q = offered * p
244    // (r1, r2) = (0, desired) ; k = r1 * p + r2 * q = desired * q
245    // Setting p = desired, q = offered gives k = offered * desired in both cases.
246    let p = desired.amount;
247    let q = offered.amount;
248
249    Position::new(
250        rng,
251        DirectedTradingPair {
252            start: offered.asset_id,
253            end: desired.asset_id,
254        },
255        fee,
256        p,
257        q,
258        super::Reserves {
259            r1: offered.amount,
260            r2: 0u64.into(),
261        },
262    )
263}
264
265impl BuyOrder {
266    pub fn into_position<R: CryptoRngCore>(&self, rng: R) -> Position {
267        into_position_inner(self.offered, self.desired, self.fee, rng)
268    }
269}
270
271impl SellOrder {
272    pub fn into_position<R: CryptoRngCore>(&self, rng: R) -> Position {
273        into_position_inner(self.offered, self.desired, self.fee, rng)
274    }
275}
276
277// TODO: maybe useful in cleaning up cli rendering?
278// unsure if we can get an exact round trip
279
280impl Position {
281    fn interpret_inner(&self) -> Option<(Value, Value)> {
282        // if r1 * r2 != 0, return None
283        // otherwise, nonzero reserves => offered,
284        // p,q imply desired,
285        let offered = if self.reserves.r1 == 0u64.into() {
286            Value {
287                amount: self.reserves.r2,
288                asset_id: self.phi.pair.asset_2(),
289            }
290        } else if self.reserves.r2 == 0u64.into() {
291            Value {
292                amount: self.reserves.r1,
293                asset_id: self.phi.pair.asset_1(),
294            }
295        } else {
296            return None;
297        };
298        // The "desired" amount is the fill against the reserves.
299        // However, we don't want to account for fees here, so make a feeless copy of
300        // the trading function.
301        let mut feeless_phi = self.phi.clone();
302        feeless_phi.component.fee = 0;
303        let (_new_reserves, desired) = feeless_phi
304            .fill_output(&self.reserves, offered)
305            .expect("asset types match")
306            .expect("supplied exact reserves");
307
308        Some((offered, desired))
309    }
310
311    /// Attempts to interpret this position as a "buy order".
312    ///
313    /// If both of the reserves are nonzero, returns None.
314    pub fn interpret_as_buy(&self) -> Option<BuyOrder> {
315        self.interpret_inner().map(|(offered, desired)| BuyOrder {
316            offered,
317            desired,
318            fee: self.phi.component.fee,
319        })
320    }
321
322    /// Attempts to interpret this position as a "sell order".
323    ///
324    /// If both of the reserves are nonzero, returns None.
325    pub fn interpret_as_sell(&self) -> Option<SellOrder> {
326        self.interpret_inner().map(|(offered, desired)| SellOrder {
327            offered,
328            desired,
329            fee: self.phi.component.fee,
330        })
331    }
332
333    /// Interprets a position with mixed reserves as a pair of "sell orders".
334    pub fn interpret_as_mixed(&self) -> Option<(SellOrder, SellOrder)> {
335        let mut split1 = self.clone();
336        let mut split2 = self.clone();
337
338        if split1.reserves.r2 == 0u64.into() {
339            return None;
340        }
341        split1.reserves.r2 = 0u64.into();
342
343        if split2.reserves.r1 == 0u64.into() {
344            return None;
345        }
346        split2.reserves.r1 = 0u64.into();
347
348        Some((
349            split1.interpret_as_sell().expect("r2 is zero"),
350            split2.interpret_as_sell().expect("r1 is zero"),
351        ))
352    }
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358
359    #[test]
360    fn parse_buy_order_basic() {
361        // TODO: should have a way to build an asset::Cache for known assets
362        let mut cache = asset::Cache::default();
363        let gm = asset::Cache::with_known_assets().get_unit("gm").unwrap();
364        let gn = asset::Cache::with_known_assets().get_unit("gn").unwrap();
365        cache.extend([gm.base(), gn.base()]);
366
367        let buy_str_1 = "123.444gm@2gn/10bps";
368        let buy_order_1 = BuyOrder::parse_str(buy_str_1).unwrap();
369        assert_eq!(
370            buy_order_1,
371            BuyOrder {
372                desired: Value {
373                    amount: 123_444000u64.into(),
374                    asset_id: gm.id()
375                },
376                offered: Value {
377                    amount: 246_888000u64.into(),
378                    asset_id: gn.id()
379                },
380                fee: 10,
381            }
382        );
383
384        let buy_formatted_1 = buy_order_1.format(&cache).unwrap();
385        assert_eq!(buy_formatted_1, buy_str_1);
386
387        let buy_position_1 = buy_order_1.into_position(rand::thread_rng());
388        let buy_position_as_order_1 = buy_position_1.interpret_as_buy().unwrap();
389        let buy_position_formatted_1 = buy_position_as_order_1.format(&cache).unwrap();
390
391        assert_eq!(buy_position_as_order_1, buy_order_1);
392        assert_eq!(buy_position_formatted_1, buy_str_1);
393    }
394
395    #[test]
396    fn parse_sell_order_basic() {
397        // TODO: should have a way to build an asset::Cache for known assets
398        let mut cache = asset::Cache::default();
399        let gm = asset::Cache::with_known_assets().get_unit("gm").unwrap();
400        let gn = asset::Cache::with_known_assets().get_unit("gn").unwrap();
401        cache.extend([gm.base(), gn.base()]);
402
403        let sell_str_1 = "123.444gm@2gn/10bps";
404        let sell_order_1 = SellOrder::parse_str(sell_str_1).unwrap();
405        assert_eq!(
406            sell_order_1,
407            SellOrder {
408                desired: Value {
409                    amount: 246_888000u64.into(),
410                    asset_id: gn.id()
411                },
412                offered: Value {
413                    amount: 123_444000u64.into(),
414                    asset_id: gm.id()
415                },
416                fee: 10,
417            }
418        );
419
420        let sell_formatted_1 = sell_order_1.format(&cache).unwrap();
421        assert_eq!(sell_formatted_1, sell_str_1);
422
423        let sell_position_1 = sell_order_1.into_position(rand::thread_rng());
424        let sell_position_as_order_1 = sell_position_1.interpret_as_sell().unwrap();
425        let sell_position_formatted_1 = sell_position_as_order_1.format(&cache).unwrap();
426
427        assert_eq!(sell_position_as_order_1, sell_order_1);
428        assert_eq!(sell_position_formatted_1, sell_str_1);
429    }
430}