penumbra_sdk_dex/component/
arb.rs

1use std::sync::Arc;
2
3use crate::component::{metrics, StateReadExt};
4use anyhow::Result;
5use async_trait::async_trait;
6use cnidarium::{StateDelta, StateWrite};
7use penumbra_sdk_asset::{asset, Value};
8use penumbra_sdk_proto::{DomainType as _, StateWriteProto as _};
9use penumbra_sdk_sct::component::clock::EpochRead;
10use tracing::instrument;
11
12use crate::{
13    component::{ExecutionCircuitBreaker, InternalDexWrite, ValueCircuitBreaker},
14    event, SwapExecution,
15};
16
17use super::router::{RouteAndFill, RoutingParams};
18
19#[async_trait]
20pub trait Arbitrage: StateWrite + Sized {
21    /// Attempts to extract as much as possible of the `arb_token` from the available
22    /// liquidity positions, and returns the amount of `arb_token` extracted.
23    #[instrument(skip(self, arb_token, routing_params))]
24    async fn arbitrage(
25        self: &mut Arc<Self>,
26        arb_token: asset::Id,
27        routing_params: RoutingParams,
28    ) -> Result<Option<Value>>
29    where
30        Self: 'static,
31    {
32        tracing::debug!(?arb_token, ?routing_params, "beginning arb search");
33        let arb_start = std::time::Instant::now();
34
35        // Work in a new `StateDelta`, so we can transactionally apply any state
36        // changes, and roll them back if we fail (e.g., if for some reason we
37        // discover at the end that the arb wasn't profitable).
38        let mut this = Arc::new(StateDelta::new(self.clone()));
39
40        // Create a flash-loan 2^64 of the arb token to ourselves.
41        let flash_loan = Value {
42            asset_id: arb_token,
43            amount: u64::MAX.into(),
44        };
45
46        let execution_budget = self.get_dex_params().await?.max_execution_budget;
47        let execution_circuit_breaker = ExecutionCircuitBreaker::new(execution_budget);
48        let Some(swap_execution) = this
49            .route_and_fill(
50                arb_token,
51                arb_token,
52                flash_loan.amount,
53                routing_params,
54                execution_circuit_breaker,
55            )
56            .await?
57        else {
58            return Ok(None);
59        };
60
61        let filled_input = swap_execution.input.amount;
62        let output = swap_execution.output.amount;
63        let unfilled_input = flash_loan
64            .amount
65            .checked_sub(&filled_input)
66            .expect("filled input should always be <= flash loan amount");
67
68        // Record the duration of the arb execution now, since we've computed it
69        // and we might return early if it's zero-valued, but in that case we still
70        // want to record how long we spent looking for it.
71        metrics::histogram!(metrics::DEX_ARB_DURATION).record(arb_start.elapsed());
72
73        // Because we're trading the arb token to itself, the total output is the
74        // output from the route-and-fill, plus the unfilled input.
75        let total_output = output + unfilled_input;
76
77        // Now "repay" the flash loan by subtracting it from the total output.
78        let Some(arb_profit) = total_output.checked_sub(&flash_loan.amount) else {
79            // This shouldn't happen, but because route-and-fill prioritizes
80            // guarantees about forward progress over precise application of
81            // price limits, it technically could occur.
82            tracing::debug!("mis-estimation in route-and-fill led to unprofitable arb, discarding");
83            return Ok(None);
84        };
85
86        if arb_profit == 0u64.into() {
87            // If we didn't make any profit, we don't need to do anything,
88            // and we can just discard the state delta entirely.
89            tracing::debug!("found 0-profit arb, discarding");
90            return Ok(None);
91        } else {
92            tracing::debug!(
93                ?filled_input,
94                ?output,
95                ?unfilled_input,
96                ?total_output,
97                ?arb_profit,
98                "arb search detected surplus!"
99            );
100        }
101
102        // TODO: this is a bit nasty, can it be simplified?
103        // should this even be done "inside" the method, or all the way at the top?
104        let (self2, cache) = Arc::try_unwrap(this)
105            .map_err(|_| ())
106            .expect("no more outstanding refs to state after routing")
107            .flatten();
108        std::mem::drop(self2);
109        // Now there is only one reference to self again
110        let mut self_mut = Arc::get_mut(self).expect("self was unique ref");
111        cache.apply_to(&mut self_mut);
112
113        // Finally, record the arb execution in the state:
114        let height = self_mut.get_block_height().await?;
115        let se = SwapExecution {
116            traces: swap_execution.traces,
117            input: Value {
118                asset_id: arb_token,
119                amount: filled_input,
120            },
121            output: Value {
122                amount: filled_input + arb_profit,
123                asset_id: arb_token,
124            },
125        };
126        self_mut.set_arb_execution(height, se.clone());
127
128        // Deduct the input surplus from the dex's VCB.
129        self_mut
130            .dex_vcb_debit(Value {
131                amount: arb_profit,
132                asset_id: arb_token,
133            })
134            .await?;
135
136        // Emit an ABCI event detailing the arb execution.
137        self_mut.record_proto(
138            event::EventArbExecution {
139                height,
140                swap_execution: se,
141            }
142            .to_proto(),
143        );
144        return Ok(Some(Value {
145            amount: arb_profit,
146            asset_id: arb_token,
147        }));
148    }
149}
150
151impl<T: StateWrite> Arbitrage for T {}