penumbra_sdk_dex/component/position_manager/
base_liquidity_index.rs

1use anyhow::Result;
2use cnidarium::StateWrite;
3use penumbra_sdk_num::Amount;
4use position::State::*;
5use tracing::instrument;
6
7use crate::lp::position::{self, Position};
8use crate::state_key::engine;
9use crate::DirectedTradingPair;
10use async_trait::async_trait;
11use penumbra_sdk_proto::{StateReadProto, StateWriteProto};
12
13#[async_trait]
14pub(crate) trait AssetByLiquidityIndex: StateWrite {
15    /// Update the base liquidity index, used by the DEX engine during path search.
16    ///
17    /// # Overview
18    /// Given a directed trading pair `A -> B`, the index tracks the amount of
19    /// liquidity available to convert the quote asset B, into a base asset A.
20    ///
21    /// # Index schema
22    /// The liquidity index schema is as follow:
23    /// - A primary index that maps a "start" asset A (aka. base asset)
24    ///   to an "end" asset B (aka. quote asset) ordered by the amount of
25    ///   liquidity available for B -> A (not a typo).
26    /// - An auxilliary index that maps a directed trading pair `A -> B`
27    ///   to the aggregate liquidity for B -> A (used in the primary composite key)
28    ///
29    /// If we want liquidity rankings for assets adjacent to A, the ranking has to be
30    /// denominated in asset A, since that’s the only way to get commensurability when
31    /// ranking B C D E etc.
32    ///
33    /// There are then two possible amounts to consider for an asset B: amount of A that
34    /// can be sold for B and amount of A that can be bought with B
35    ///
36    /// (1), amount that can be sold (“outbound”) is the wrong thing to use
37    /// (2), amount that can be bought, is intuitively the “opposite” of what we want,
38    ///      since it’s the reverse direction, but is actually the right thing to use as
39    ///      a rough proxy for liquidity
40    ///
41    /// The reason is that (1) can be easily manipulated without any skin in the game, by
42    /// offering to sell a tiny amount of B for A at an outrageous/infinite price.
43    ///
44    ///
45    /// # Diagram
46    ///                                                                     
47    ///    Liquidity index:                                                 
48    ///    For an asset `A`, surface asset                                  
49    ///    `B` with the best liquidity                                      
50    ///    score.                                                           
51    ///                             ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐     
52    ///                                                                     
53    ///           ┌──┐              ▼            ┌─────────┐          │     
54    ///     ▲     │  │    ┌──────────────────┐   │         │                
55    ///     │     │ ─┼───▶│{asset_A}{agg_liq}│──▶│{asset_B}│          │     
56    ///     │     ├──┤    └──────────────────┘   │         │                
57    ///   sorted  │  │                           └─────────┘          │     
58    ///   by agg  │  │                                                      
59    ///    liq    ├──┤                                                │     
60    ///     │     │  │                                           used in the
61    ///     │     ├──┤                                            composite
62    ///     │     │  │                                               key    
63    ///     │     │  │       Auxiliary look-up index:                 │     
64    ///     │     │  │       "Find the aggregate liquidity                 
65    ///     │     │  │       per directed trading pair"               │      
66    ///     │     │  │       ┌───────┐                           ┌─────────┐
67    ///     │     │  │       ├───────┤  ┌──────────────────┐     │         │
68    ///     │     │  │       │   ────┼─▶│{asset_A}{asset_B}│────▶│{agg_liq}│
69    ///     │     ├──┤       ├───────┤  └──────────────────┘     │         │
70    ///     │     │  │       ├───────┤                           └─────────┘
71    ///     │     │  │       ├───────┤                                      
72    ///     │     │  │       ├───────┤                                      
73    ///     │     ├──┤       └───────┘                                      
74    ///     │     │  │                                                      
75    ///     │     │  │                                                      
76    ///     │     └──┘                                                      
77    async fn update_asset_by_base_liquidity_index(
78        &mut self,
79        id: &position::Id,
80        prev_state: &Option<Position>,
81        new_state: &Position,
82    ) -> Result<()> {
83        // We need to reconstruct the position's previous contribution and compute
84        // its new contribution to the index. We do this for each asset in the pair
85        // and short-circuit if all contributions are zero.
86        let canonical_pair = new_state.phi.pair;
87        let pair_ab = DirectedTradingPair::new(canonical_pair.asset_1(), canonical_pair.asset_2());
88
89        // We reconstruct the position's *previous* contribution so that we can deduct them later:
90        let (prev_a, prev_b) = match prev_state {
91            // The position was just created, so its previous contributions are zero.
92            None => (Amount::zero(), Amount::zero()),
93            Some(prev) => match prev.state {
94                // The position was previously closed or withdrawn, so its previous contributions are zero.
95                Closed | Withdrawn { sequence: _ } => (Amount::zero(), Amount::zero()),
96                // The position's previous contributions are the reserves for the start and end assets.
97                _ => (
98                    prev.reserves_for(pair_ab.start)
99                        .expect("asset ids match for start"),
100                    prev.reserves_for(pair_ab.end)
101                        .expect("asset ids match for end"),
102                ),
103            },
104        };
105
106        // For each asset, we compute the new position's contribution to the index:
107        let (new_a, new_b) = if matches!(new_state.state, Closed | Withdrawn { sequence: _ }) {
108            // The position is being closed or withdrawn, so its new contributions are zero.
109            // Note a withdrawn position MUST have zero reserves, so hardcoding this is extra.
110            (Amount::zero(), Amount::zero())
111        } else {
112            (
113                // The new amount of asset A:
114                new_state
115                    .reserves_for(pair_ab.start)
116                    .expect("asset ids match for start"),
117                // The new amount of asset B:
118                new_state
119                    .reserves_for(pair_ab.end)
120                    .expect("asset ids match for end"),
121            )
122        };
123
124        // If all contributions are zero, we can skip the update.
125        // This can happen if we're processing inactive transitions like `Closed -> Withdrawn`.
126        if prev_a == Amount::zero()
127            && new_a == Amount::zero()
128            && prev_b == Amount::zero()
129            && new_b == Amount::zero()
130        {
131            return Ok(());
132        }
133
134        // A -> B
135        self.update_asset_by_base_liquidity_index_inner(id, pair_ab, prev_a, new_a)
136            .await?;
137        // B -> A
138        self.update_asset_by_base_liquidity_index_inner(id, pair_ab.flip(), prev_b, new_b)
139            .await?;
140
141        Ok(())
142    }
143}
144
145impl<T: StateWrite + ?Sized> AssetByLiquidityIndex for T {}
146
147trait Inner: StateWrite {
148    #[instrument(skip(self))]
149    async fn update_asset_by_base_liquidity_index_inner(
150        &mut self,
151        id: &position::Id,
152        pair: DirectedTradingPair,
153        old_contrib: Amount,
154        new_contrib: Amount,
155    ) -> Result<()> {
156        let aggregate_key = &engine::routable_assets::lookup_base_liquidity_by_pair(&pair);
157
158        let prev_tally: Amount = self
159            .nonverifiable_get(aggregate_key)
160            .await?
161            .unwrap_or_default();
162
163        // To compute the new aggregate liquidity, we deduct the old contribution
164        // and add the new contribution. We use saturating arithmetic defensively.
165        let new_tally = prev_tally
166            .saturating_sub(&old_contrib)
167            .saturating_add(&new_contrib);
168
169        // If the update operation is a no-op, we can skip the update and return early.
170        if prev_tally == new_tally {
171            tracing::trace!(
172                ?prev_tally,
173                ?pair,
174                ?id,
175                "skipping routable asset index update"
176            );
177            return Ok(());
178        }
179
180        // Update the primary and auxiliary indices:
181        let old_primary_key = engine::routable_assets::key(&pair.start, prev_tally).to_vec();
182        // This could make the `StateDelta` more expensive to scan, but this doesn't show on profiles yet.
183        self.nonverifiable_delete(old_primary_key);
184
185        let new_primary_key = engine::routable_assets::key(&pair.start, new_tally).to_vec();
186        self.nonverifiable_put(new_primary_key, pair.end);
187        tracing::debug!(?pair, ?new_tally, "base liquidity entry updated");
188
189        let auxiliary_key = engine::routable_assets::lookup_base_liquidity_by_pair(&pair).to_vec();
190        self.nonverifiable_put(auxiliary_key, new_tally);
191        tracing::trace!(
192            ?pair,
193            "base liquidity heuristic marked directed pair as routable"
194        );
195
196        Ok(())
197    }
198}
199
200impl<T: StateWrite + ?Sized> Inner for T {}