-
Notifications
You must be signed in to change notification settings - Fork 305
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
dex: refactor internal position update logic (#4188)
## Describe your changes This PR: - break out indexing methods from the `Inner` position manager trait into submodules with crate visibility - refactor `update_position` to delegate indexing checks to `update_*_index` methods - add a redundant guard against invalid transitions in `PositionManager::update_position` - streamline the base liquidity index, fixing a bug with double counting closed positions This PR contain changes to critical parts of the DEX engine internals. ## Checklist before requesting a review - [x] If this code contains consensus-breaking changes, I have added the "consensus-breaking" label. Otherwise, I declare my belief that there are not consensus-breaking changes, for the following reason: > This is technically consensus breaking because we fix a bug in the base liquidity index logic, which could trickle down into different DEX execution in some rare cases.
- Loading branch information
Showing
9 changed files
with
486 additions
and
332 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
339 changes: 56 additions & 283 deletions
339
crates/core/component/dex/src/component/position_manager.rs
Large diffs are not rendered by default.
Oops, something went wrong.
180 changes: 180 additions & 0 deletions
180
crates/core/component/dex/src/component/position_manager/base_liquidity_index.rs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,180 @@ | ||
use anyhow::Result; | ||
use cnidarium::StateWrite; | ||
use penumbra_num::Amount; | ||
use position::State::*; | ||
|
||
use crate::lp::position::{self, Position}; | ||
use crate::state_key::engine; | ||
use crate::DirectedTradingPair; | ||
use penumbra_proto::{StateReadProto, StateWriteProto}; | ||
|
||
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) | ||
/// | ||
/// # 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, | ||
prev_state: &Option<Position>, | ||
new_state: &Position, | ||
id: &position::Id, | ||
) -> 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 { | ||
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::debug!( | ||
?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::debug!( | ||
?pair, | ||
"base liquidity heuristic marked directed pair as routable" | ||
); | ||
|
||
Ok(()) | ||
} | ||
} | ||
|
||
impl<T: StateWrite + ?Sized> Inner for T {} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.