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