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 {}