penumbra_sdk_dex/lp/
position.rs

1use anyhow::{anyhow, Context};
2use penumbra_sdk_asset::{asset, Value};
3use penumbra_sdk_num::Amount;
4use penumbra_sdk_proto::{
5    penumbra::core::component::dex::v1 as pb, serializers::bech32str, DomainType,
6};
7use rand_core::CryptoRngCore;
8use serde::{Deserialize, Serialize};
9
10use crate::{DirectedTradingPair, TradingPair};
11
12use super::{trading_function::TradingFunction, Reserves};
13
14/// Reserve amounts for positions must be at most 80 bits wide.
15pub const MAX_RESERVE_AMOUNT: u128 = (1 << 80) - 1;
16
17/// A trading function's fee (spread) must be at most 50% (5000 bps)
18pub const MAX_FEE_BPS: u32 = 5000;
19
20/// Encapsulates the immutable parts of the position (phi/nonce), along
21/// with the mutable parts (state/reserves).
22#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
23#[serde(try_from = "pb::Position", into = "pb::Position")]
24pub struct Position {
25    pub state: State,
26    pub reserves: Reserves,
27    /// A trading function to a specific trading pair.
28    pub phi: TradingFunction,
29    /// A random value used to disambiguate different positions with the exact
30    /// same trading function.  The position ID is a hash of the trading
31    /// function and the nonce; the chain rejects transactions creating
32    /// duplicate position [`Id`]s, so it can track position ownership with a
33    /// sequence of stateful NFTs based on the [`Id`].
34    pub nonce: [u8; 32],
35    /// Set to `true` if a position is a limit-order, meaning that it will be closed after being
36    /// filled against.
37    pub close_on_fill: bool,
38}
39
40impl std::fmt::Debug for Position {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        f.debug_struct("Position")
43            .field("state", &self.state)
44            .field("reserves", &self.reserves)
45            .field("phi", &self.phi)
46            .field("nonce", &hex::encode(self.nonce))
47            .finish()
48    }
49}
50
51impl Position {
52    /// Construct a new opened [Position] with a random nonce.
53    ///
54    /// The `p` value is the coefficient for the position's trading function that will be
55    /// associated with the start asset, and the `q` value is the coefficient for the end asset.
56    ///
57    /// The reserves `r1` and `r2` also correspond to the start and end assets, respectively.
58    pub fn new<R: CryptoRngCore>(
59        mut rng: R,
60        pair: DirectedTradingPair,
61        fee: u32,
62        p: Amount,
63        q: Amount,
64        reserves: Reserves,
65    ) -> Position {
66        // Internally mutable so we can swap the `p` and `q` values if necessary.
67        let mut p = p;
68        let mut q = q;
69        let mut reserves = reserves;
70
71        let mut nonce_bytes = [0u8; 32];
72        rng.fill_bytes(&mut nonce_bytes);
73
74        // The [`TradingFunction`] uses a canonical non-directed trading pair ([`TradingPair`]).
75        // This means that the `p` and `q` values may need to be swapped, depending on the canonical
76        // representation of the trading pair.
77        let canonical_tp: TradingPair = pair.into();
78
79        // The passed-in `p` value is associated with the start asset, as is `r1`.
80        if pair.start != canonical_tp.asset_1() {
81            // The canonical representation of the trading pair has the start asset as the second
82            // asset, so we need to swap the `p` and `q` values.
83            std::mem::swap(&mut p, &mut q);
84
85            // The ordering of the reserves should also be swapped.
86            reserves = Reserves {
87                r1: reserves.r2,
88                r2: reserves.r1,
89            };
90        }
91
92        let phi = TradingFunction::new(canonical_tp, fee, p, q);
93        Position {
94            phi,
95            nonce: nonce_bytes,
96            state: State::Opened,
97            reserves,
98            close_on_fill: false,
99        }
100    }
101
102    /// Construct a new opened [Position] with a supplied random nonce.
103    pub fn new_with_nonce(
104        nonce: [u8; 32],
105        pair: DirectedTradingPair,
106        fee: u32,
107        p: Amount,
108        q: Amount,
109        reserves: Reserves,
110    ) -> Position {
111        // Internally mutable so we can swap the `p` and `q` values if necessary.
112        let mut p = p;
113        let mut q = q;
114        let mut reserves = reserves;
115
116        // The [`TradingFunction`] uses a canonical non-directed trading pair ([`TradingPair`]).
117        // This means that the `p` and `q` values may need to be swapped, depending on the canonical
118        // representation of the trading pair.
119        let canonical_tp: TradingPair = pair.into();
120
121        // The passed-in `p` value is associated with the start asset, as is `r1`.
122        if pair.start != canonical_tp.asset_1() {
123            // The canonical representation of the trading pair has the start asset as the second
124            // asset, so we need to swap the `p` and `q` values.
125            std::mem::swap(&mut p, &mut q);
126
127            // The ordering of the reserves should also be swapped.
128            reserves = Reserves {
129                r1: reserves.r2,
130                r2: reserves.r1,
131            };
132        }
133
134        let phi = TradingFunction::new(canonical_tp, fee, p, q);
135        Position {
136            phi,
137            nonce,
138            state: State::Opened,
139            reserves,
140            close_on_fill: false,
141        }
142    }
143
144    /// Get the ID of this position.
145    pub fn id(&self) -> Id {
146        let mut state = blake2b_simd::Params::default()
147            .personal(b"penumbra_lp_id")
148            .to_state();
149
150        state.update(&self.nonce);
151        state.update(&self.phi.pair.asset_1().to_bytes());
152        state.update(&self.phi.pair.asset_2().to_bytes());
153        state.update(&self.phi.component.fee.to_le_bytes());
154        state.update(&self.phi.component.p.to_le_bytes());
155        state.update(&self.phi.component.q.to_le_bytes());
156
157        let hash = state.finalize();
158        let mut bytes = [0; 32];
159        bytes[0..32].copy_from_slice(&hash.as_bytes()[0..32]);
160        Id(bytes)
161    }
162
163    pub fn check_stateless(&self) -> anyhow::Result<()> {
164        if self.reserves.r1.value() > MAX_RESERVE_AMOUNT
165            || self.reserves.r2.value() > MAX_RESERVE_AMOUNT
166        {
167            Err(anyhow::anyhow!(format!(
168                "Reserve amounts are out-of-bounds (limit: {MAX_RESERVE_AMOUNT})"
169            )))
170        } else if self.reserves.r1.value() == 0 && self.reserves.r2.value() == 0 {
171            Err(anyhow::anyhow!(
172                "initial reserves must provision some amount of either asset",
173            ))
174        } else if self.phi.component.p == 0u64.into() || self.phi.component.q == 0u64.into() {
175            Err(anyhow::anyhow!(
176                "trading function coefficients must be nonzero"
177            ))
178        } else if self.phi.component.p.value() > MAX_RESERVE_AMOUNT
179            || self.phi.component.q.value() > MAX_RESERVE_AMOUNT
180        {
181            Err(anyhow!("trading function coefficients are too large"))
182        } else if self.phi.pair.asset_1() == self.phi.pair.asset_2() {
183            Err(anyhow!("cyclical pairs aren't allowed"))
184        } else if self.phi.component.fee > MAX_FEE_BPS {
185            Err(anyhow!("fee cannot be greater than 50% (5000bps)"))
186        } else {
187            Ok(())
188        }
189    }
190
191    /// Returns the amount of the given asset that is currently in the position's reserves.
192    pub fn reserves_for(&self, asset: asset::Id) -> Option<Amount> {
193        if asset == self.phi.pair.asset_1() {
194            Some(self.reserves.r1)
195        } else if asset == self.phi.pair.asset_2() {
196            Some(self.reserves.r2)
197        } else {
198            None
199        }
200    }
201
202    /// Returns the amount of reserves for asset 1.
203    pub fn reserves_1(&self) -> Value {
204        Value {
205            amount: self.reserves.r1,
206            asset_id: self.phi.pair.asset_1(),
207        }
208    }
209
210    /// Returns the amount of reserves for asset 2.
211    pub fn reserves_2(&self) -> Value {
212        Value {
213            amount: self.reserves.r2,
214            asset_id: self.phi.pair.asset_2(),
215        }
216    }
217}
218
219/// A hash of a [`Position`].
220#[derive(PartialEq, Eq, PartialOrd, Ord, Copy, Clone, Serialize, Deserialize)]
221#[serde(try_from = "pb::PositionId", into = "pb::PositionId")]
222pub struct Id(pub [u8; 32]);
223
224impl std::fmt::Debug for Id {
225    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
226        f.write_str(&bech32str::encode(
227            &self.0,
228            bech32str::lp_id::BECH32_PREFIX,
229            bech32str::Bech32m,
230        ))
231    }
232}
233
234impl std::fmt::Display for Id {
235    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
236        f.write_str(&bech32str::encode(
237            &self.0,
238            bech32str::lp_id::BECH32_PREFIX,
239            bech32str::Bech32m,
240        ))
241    }
242}
243
244impl std::str::FromStr for Id {
245    type Err = anyhow::Error;
246
247    fn from_str(s: &str) -> Result<Self, Self::Err> {
248        let inner = bech32str::decode(s, bech32str::lp_id::BECH32_PREFIX, bech32str::Bech32m)?;
249        pb::PositionId {
250            inner,
251            alt_bech32m: String::new(),
252        }
253        .try_into()
254    }
255}
256
257/// The state of a position.
258#[derive(Debug, PartialEq, Eq, Copy, Clone, Serialize, Deserialize)]
259#[serde(try_from = "pb::PositionState", into = "pb::PositionState")]
260pub enum State {
261    /// The position has been opened, is active, has reserves and accumulated
262    /// fees, and can be traded against.
263    Opened,
264    /// The position has been closed, is inactive and can no longer be traded
265    /// against, but still has reserves and accumulated fees.
266    Closed,
267    /// The final reserves and accumulated fees have been withdrawn, leaving an
268    /// empty, inactive position awaiting (possible) retroactive rewards.
269    Withdrawn {
270        /// The sequence number, incrementing with each withdrawal.
271        sequence: u64,
272    },
273}
274
275impl std::fmt::Display for State {
276    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
277        match self {
278            State::Opened => write!(f, "opened"),
279            State::Closed => write!(f, "closed"),
280            State::Withdrawn { sequence } => write!(f, "withdrawn_{}", sequence),
281        }
282    }
283}
284
285impl std::str::FromStr for State {
286    type Err = anyhow::Error;
287
288    fn from_str(s: &str) -> Result<Self, Self::Err> {
289        match s {
290            "opened" => Ok(State::Opened),
291            "closed" => Ok(State::Closed),
292            _ => {
293                let mut parts = s.splitn(2, '_');
294                if parts.next() == Some("withdrawn") {
295                    let sequence = parts
296                        .next()
297                        .ok_or_else(|| anyhow::anyhow!("missing sequence number"))?
298                        .parse()?;
299                    Ok(State::Withdrawn { sequence })
300                } else {
301                    Err(anyhow::anyhow!("unknown position state"))
302                }
303            }
304        }
305    }
306}
307
308// ==== Protobuf impls
309
310impl DomainType for Position {
311    type Proto = pb::Position;
312}
313
314impl DomainType for Id {
315    type Proto = pb::PositionId;
316}
317
318impl TryFrom<pb::PositionId> for Id {
319    type Error = anyhow::Error;
320
321    fn try_from(value: pb::PositionId) -> Result<Self, Self::Error> {
322        match (value.inner.is_empty(), value.alt_bech32m.is_empty()) {
323            (false, true) => Ok(Id(value
324                .inner
325                .as_slice()
326                .try_into()
327                .context("expected 32-byte id")?)),
328            (true, false) => value.alt_bech32m.parse(),
329            (false, false) => Err(anyhow::anyhow!(
330                "AssetId proto has both inner and alt_bech32m fields set"
331            )),
332            (true, true) => Err(anyhow::anyhow!(
333                "AssetId proto has neither inner nor alt_bech32m fields set"
334            )),
335        }
336    }
337}
338
339impl From<Id> for pb::PositionId {
340    fn from(value: Id) -> Self {
341        Self {
342            inner: value.0.to_vec(),
343            // Never produce a proto encoding with the alt field set.
344            alt_bech32m: String::new(),
345        }
346    }
347}
348
349impl DomainType for State {
350    type Proto = pb::PositionState;
351}
352
353impl From<State> for pb::PositionState {
354    fn from(v: State) -> Self {
355        pb::PositionState {
356            state: match v {
357                State::Opened => pb::position_state::PositionStateEnum::Opened,
358                State::Closed => pb::position_state::PositionStateEnum::Closed,
359                State::Withdrawn { .. } => pb::position_state::PositionStateEnum::Withdrawn,
360            } as i32,
361            sequence: match v {
362                State::Withdrawn { sequence } => sequence,
363                // This will be omitted from serialization.
364                _ => 0,
365            },
366        }
367    }
368}
369
370impl TryFrom<pb::PositionState> for State {
371    type Error = anyhow::Error;
372    fn try_from(v: pb::PositionState) -> Result<Self, Self::Error> {
373        let position_state =
374            pb::position_state::PositionStateEnum::try_from(v.state).map_err(|e| {
375                anyhow::anyhow!(
376                    "invalid position state enum value: {}, error: {}",
377                    v.state,
378                    e
379                )
380            })?;
381
382        match position_state {
383            pb::position_state::PositionStateEnum::Opened => Ok(State::Opened),
384            pb::position_state::PositionStateEnum::Closed => Ok(State::Closed),
385            pb::position_state::PositionStateEnum::Withdrawn => Ok(State::Withdrawn {
386                sequence: v.sequence,
387            }),
388            _ => Err(anyhow!("unknown position state")),
389        }
390    }
391}
392
393impl From<Position> for pb::Position {
394    fn from(p: Position) -> Self {
395        Self {
396            state: Some(p.state.into()),
397            reserves: Some(p.reserves.into()),
398            phi: Some(p.phi.into()),
399            nonce: p.nonce.to_vec(),
400            close_on_fill: p.close_on_fill,
401        }
402    }
403}
404
405impl TryFrom<pb::Position> for Position {
406    type Error = anyhow::Error;
407    fn try_from(p: pb::Position) -> Result<Self, Self::Error> {
408        Ok(Self {
409            state: p
410                .state
411                .ok_or_else(|| anyhow::anyhow!("missing state in Position message"))?
412                .try_into()?,
413            reserves: p
414                .reserves
415                .ok_or_else(|| anyhow::anyhow!("missing reserves in Position message"))?
416                .try_into()?,
417            phi: p
418                .phi
419                .ok_or_else(|| anyhow::anyhow!("missing trading function"))?
420                .try_into()?,
421            nonce: p
422                .nonce
423                .as_slice()
424                .try_into()
425                .context("expected 32-byte nonce")?,
426            close_on_fill: p.close_on_fill,
427        })
428    }
429}
430
431#[cfg(test)]
432mod test {
433    use super::*;
434    use ark_ff::Zero;
435    use decaf377::Fq;
436    use penumbra_sdk_asset::asset;
437    use rand_core::OsRng;
438
439    #[test]
440    fn position_state_fromstr() {
441        assert_eq!("opened".parse::<State>().unwrap(), State::Opened);
442        assert_eq!("closed".parse::<State>().unwrap(), State::Closed);
443        assert_eq!(
444            "withdrawn_0".parse::<State>().unwrap(),
445            State::Withdrawn { sequence: 0 }
446        );
447        assert_eq!(
448            "withdrawn_1".parse::<State>().unwrap(),
449            State::Withdrawn { sequence: 1 }
450        );
451        assert!("withdrawn".parse::<State>().is_err());
452        assert!("withdrawn_".parse::<State>().is_err());
453        assert!("withdrawn_1_".parse::<State>().is_err());
454        assert!("withdrawn_1_2".parse::<State>().is_err());
455        assert!("withdrawn_1_2_3".parse::<State>().is_err());
456    }
457
458    fn assert_position_similar(p1: Position, p2: Position) {
459        assert_eq!(p1.reserves.r1, p2.reserves.r1);
460        assert_eq!(p1.reserves.r2, p2.reserves.r2);
461        assert_eq!(p1.phi.component.p, p2.phi.component.p);
462        assert_eq!(p1.phi.component.q, p2.phi.component.q);
463    }
464
465    fn assert_position_not_similar(p1: Position, p2: Position) {
466        let different_reserves = p1.reserves.r1 != p2.reserves.r1;
467        let different_reserves = different_reserves || p1.reserves.r2 != p2.reserves.r2;
468        let different_prices = p1.phi.component.p != p2.phi.component.p;
469        let different_prices = different_prices || p1.phi.component.q != p2.phi.component.q;
470        assert!(different_prices || different_reserves);
471    }
472    #[test]
473    fn test_position() {
474        let small_id = asset::Id(Fq::zero());
475        let big_id = asset::Id(Fq::from(1u64));
476
477        let pair_1 = DirectedTradingPair::new(small_id, big_id);
478        let pair_2 = DirectedTradingPair::new(big_id, small_id);
479
480        let price100i: (Amount, Amount) = (1u64.into(), 100u64.into());
481
482        /*
483           We create four positions per pair, where id(A) < id(B):
484               + Case 1: for pair 1 (A -> B):
485                   * position 1: provisions 150 units of asset 1 (A) at a price of 1/100.
486                   * position 2: provisions 150 units of asset 2 (B) at a price of 100.
487                   * position 3: provisions 150 units of asset 1 (A) at a price of 100.
488                   * position 4: provisions 150 units of asset 2 (B) at a price of 1/100.
489               + Case 2: for pair 2 (B -> A):
490                   * position 1: provisions 150 units of asset 1 (B) at a price of 1/100.
491                   * position 2: provisions 150 units fo asset 2 (A) at a price of 100.
492                   * position 3: provisions 150 units of asset 1 (B) at a price of 100.
493                   * position 4: provisions 150 units of asset 2 (A) at a price of 1/100.
494
495           We want to check that:
496               1. Case_1.p1 != Case_2.p2
497               2. Case_1.p2 != Case_2.p1
498               3. Case_1.p3 == Case_2.p2
499               4. Case_1.p4 == Case_2.p1
500               5. Case_2.p3 == Case_1.p2
501               6. Case_2.p4 == Case_1.p1
502        */
503
504        let reserves_1 = Reserves {
505            r1: 150u64.into(),
506            r2: 0u64.into(),
507        };
508
509        let reserves_2 = reserves_1.flip();
510
511        let a_position_1 = Position::new(
512            OsRng,
513            pair_1,
514            0u32,
515            price100i.0,
516            price100i.1,
517            reserves_1.clone(),
518        );
519        let a_position_2 = Position::new(
520            OsRng,
521            pair_1,
522            0u32,
523            price100i.0,
524            price100i.1,
525            reserves_2.clone(),
526        );
527
528        let a_position_3 = Position::new(
529            OsRng,
530            pair_1,
531            0u32,
532            price100i.1,
533            price100i.0,
534            reserves_1.clone(),
535        );
536        let a_position_4 = Position::new(
537            OsRng,
538            pair_1,
539            0u32,
540            price100i.1,
541            price100i.0,
542            reserves_2.clone(),
543        );
544
545        let b_position_1 = Position::new(
546            OsRng,
547            pair_2,
548            0u32,
549            price100i.0,
550            price100i.1,
551            reserves_1.clone(),
552        );
553        let b_position_2 = Position::new(
554            OsRng,
555            pair_2,
556            0u32,
557            price100i.0,
558            price100i.1,
559            reserves_2.clone(),
560        );
561
562        let b_position_3 = Position::new(
563            OsRng,
564            pair_2,
565            0u32,
566            price100i.1,
567            price100i.0,
568            reserves_1.clone(),
569        );
570        let b_position_4 = Position::new(
571            OsRng,
572            pair_2,
573            0u32,
574            price100i.1,
575            price100i.0,
576            reserves_2.clone(),
577        );
578
579        /*
580                We want to check that:
581                1. Case_1.p1 != Case_2.p2
582                2. Case_1.p2 != Case_2.p1
583                3. Case_1.p3 == Case_2.p2
584                4. Case_1.p4 == Case_2.p1
585                5. Case_2.p3 == Case_1.p2
586                6. Case_2.p4 == Case_1.p1
587        */
588        assert_position_not_similar(a_position_1.clone(), b_position_2.clone());
589        assert_position_not_similar(a_position_2.clone(), b_position_1.clone());
590        assert_position_similar(a_position_3, b_position_2);
591        assert_position_similar(a_position_4, b_position_1);
592        assert_position_similar(b_position_3, a_position_2);
593        assert_position_similar(b_position_4, a_position_1);
594    }
595}