diff --git a/Cargo.lock b/Cargo.lock index a18b0ac..69ce292 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3883,7 +3883,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vesu-liquidator" -version = "0.1.0" +version = "0.3.1" dependencies = [ "anyhow", "apibara-core", diff --git a/Cargo.toml b/Cargo.toml index d660862..da53fbb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vesu-liquidator" -version = "0.3.0" +version = "0.3.1" edition = "2021" license = "MIT" homepage = "https://www.vesu.xyz/" diff --git a/build.rs b/build.rs index d89686b..47b31de 100644 --- a/build.rs +++ b/build.rs @@ -22,8 +22,11 @@ fn main() { let contract_files = strk_abi_base.join(format!("vesu_liquidate_{abi_file}.contract_class.json")); let contract_files = contract_files.to_str().unwrap(); - let abigen = cainome::rs::Abigen::new(abi_file, contract_files) - .with_derives(vec!["serde::Deserialize".to_string()]); + let abigen = cainome::rs::Abigen::new(abi_file, contract_files).with_derives(vec![ + "Debug".into(), + "serde::Deserialize".into(), + "serde::Serialize".into(), + ]); abigen .generate() diff --git a/src/services/indexer.rs b/src/services/indexer.rs index 7bf1b0c..74eba05 100644 --- a/src/services/indexer.rs +++ b/src/services/indexer.rs @@ -27,7 +27,7 @@ use crate::{ utils::conversions::{apibara_field_as_felt, felt_as_apibara_field}, }; -const INDEXING_STREAM_CHUNK_SIZE: usize = 1024; +const INDEXING_STREAM_CHUNK_SIZE: usize = 1; pub struct IndexerService { config: Config, @@ -167,7 +167,11 @@ impl IndexerService { } let position_key = new_position.key(); if self.seen_positions.insert(position_key) { - tracing::info!("[🔍 Indexer] Found new position 0x{:x}", new_position.key()); + tracing::info!( + "[🔍 Indexer] Found new position 0x{:x} at block {}", + new_position.key(), + block_number + ); } match self.positions_sender.try_send((block_number, new_position)) { Ok(_) => {} diff --git a/src/services/monitoring.rs b/src/services/monitoring.rs index 77d1246..6c433fb 100644 --- a/src/services/monitoring.rs +++ b/src/services/monitoring.rs @@ -160,9 +160,9 @@ impl MonitoringService { ) .await?; let execution_fees = self.account.estimate_fees_cost(&liquidation_txs).await?; - let slippage = BigDecimal::new(BigInt::from(5), 2); let slippage_factor = BigDecimal::from(1) - slippage; + Ok(( (simulated_profit * slippage_factor) - execution_fees, liquidation_txs, diff --git a/src/types/position.rs b/src/types/position.rs index 487a76b..2696db0 100644 --- a/src/types/position.rs +++ b/src/types/position.rs @@ -1,10 +1,11 @@ -use anyhow::{anyhow, Ok, Result}; +use anyhow::{anyhow, Context, Ok, Result}; use apibara_core::starknet::v1alpha2::FieldElement; use bigdecimal::num_bigint::BigInt; -use bigdecimal::BigDecimal; -use cainome::cairo_serde::CairoSerde; +use bigdecimal::{BigDecimal, FromPrimitive}; +use cainome::cairo_serde::{ContractAddress, U256}; use colored::Colorize; use serde::{Deserialize, Serialize}; +use serde_json::Value; use starknet::core::types::{BlockId, BlockTag, FunctionCall}; use starknet::core::types::{Call, Felt}; use starknet::providers::jsonrpc::HttpTransport; @@ -15,26 +16,27 @@ use std::hash::{Hash, Hasher}; use std::sync::Arc; use tokio::sync::RwLock; -use crate::bindings::liquidate::{Liquidate, LiquidateParams, RouteNode, Swap, TokenAmount, I129}; +use crate::bindings::liquidate::{ + Liquidate, LiquidateParams, PoolKey, RouteNode, Swap, TokenAmount, I129, +}; use crate::config::{Config, LiquidationMode, LIQUIDATION_CONFIG_SELECTOR}; use crate::services::oracle::LatestOraclePrices; use crate::storages::Storage; use crate::utils::apply_overhead; use crate::utils::constants::VESU_RESPONSE_DECIMALS; +use crate::utils::conversions::big_decimal_to_felt; use crate::{types::asset::Asset, utils::conversions::apibara_field_as_felt}; use super::account::StarknetAccount; +/// Threshold for which we consider a position almost liquidable. +const ALMOST_LIQUIDABLE_THRESHOLD: f64 = 0.03; + /// Thread-safe wrapper around the positions. /// PositionsMap is a map between position position_key <=> position. pub struct PositionsMap(pub Arc>>); -#[derive(Deserialize)] -pub struct EkuboApiGetRouteResponse { - route: Vec, -} - impl PositionsMap { pub fn new() -> Self { Self(Arc::new(RwLock::new(HashMap::new()))) @@ -173,15 +175,22 @@ impl Position { .await .expect("failed to retrieve ltv ratio"); - let is_liquidable = ltv_ratio > self.lltv; - if is_liquidable { - self.debug_position_state(is_liquidable, ltv_ratio); + let is_liquidable = ltv_ratio >= self.lltv.clone(); + let is_almost_liquidable = ltv_ratio + >= self.lltv.clone() - BigDecimal::from_f64(ALMOST_LIQUIDABLE_THRESHOLD).unwrap(); + if is_liquidable || is_almost_liquidable { + self.debug_position_state(is_liquidable, is_almost_liquidable, ltv_ratio); } is_liquidable } /// Prints the status of the position and if it's liquidable or not. - fn debug_position_state(&self, is_liquidable: bool, ltv_ratio: BigDecimal) { + fn debug_position_state( + &self, + is_liquidable: bool, + is_almost_liquidable: bool, + ltv_ratio: BigDecimal, + ) { tracing::info!( "{} is at ratio {:.2}%/{:.2}% => {}", self, @@ -189,6 +198,8 @@ impl Position { self.lltv.clone() * BigDecimal::from(100), if is_liquidable { "liquidable!".green() + } else if is_almost_liquidable { + "almost liquidable 🔫".yellow() } else { "NOT liquidable.".red() } @@ -248,14 +259,77 @@ impl Position { "https://mainnet-api.ekubo.org/quote/{amount_as_string}/{from_token}/{to_token}" ); let http_client = reqwest::Client::new(); + let response = http_client.get(ekubo_api_endpoint).send().await?; - let ekubo_response: EkuboApiGetRouteResponse = response.json().await?; - Ok(ekubo_response.route) + + if !response.status().is_success() { + anyhow::bail!("API request failed with status: {}", response.status()); + } + + let response_text = response.text().await?; + + let json_value: Value = serde_json::from_str(&response_text)?; + + // We have to deserialize by hand into a Vec of [RouteNode]. + // TODO: Make this cleaner! + let route = json_value["route"] + .as_array() + .context("'route' is not an array")? + .iter() + .map(|node| { + let pool_key = &node["pool_key"]; + Ok(RouteNode { + pool_key: PoolKey { + token0: ContractAddress(Felt::from_hex( + pool_key["token0"] + .as_str() + .context("token0 is not a string")?, + )?), + token1: ContractAddress(Felt::from_hex( + pool_key["token1"] + .as_str() + .context("token1 is not a string")?, + )?), + fee: u128::from_str_radix( + pool_key["fee"] + .as_str() + .context("fee is not a string")? + .trim_start_matches("0x"), + 16, + ) + .context("Failed to parse fee as u128")?, + tick_spacing: pool_key["tick_spacing"] + .as_u64() + .context("tick_spacing is not a u64")? + as u128, + extension: ContractAddress(Felt::from_hex( + pool_key["extension"] + .as_str() + .context("extension is not a string")?, + )?), + }, + sqrt_ratio_limit: U256::from_bytes_be( + &Felt::from_hex( + node["sqrt_ratio_limit"] + .as_str() + .context("sqrt_ratio_limit is not a string")?, + ) + .unwrap() + .to_bytes_be(), + ), + skip_ahead: node["skip_ahead"] + .as_u64() + .context("skip_ahead is not a u64")? + as u128, + }) + }) + .collect::>>()?; + + Ok(route) } /// Returns the TX necessary to liquidate this position (approve + liquidate). // See: https://github.com/vesuxyz/vesu-v1/blob/a2a59936988fcb51bc85f0eeaba9b87cf3777c49/src/singleton.cairo#L1624 - #[allow(unused)] pub async fn get_liquidation_txs( &self, account: &StarknetAccount, @@ -266,59 +340,44 @@ impl Position { // The amount is in negative because contract use a inverted route to ensure that we get the exact amount of debt token let liquidate_token = TokenAmount { token: cainome::cairo_serde::ContractAddress(self.debt.address), - amount: I129::cairo_deserialize(&[Felt::ZERO], 0)?, + amount: I129 { mag: 0, sign: true }, }; let withdraw_token = TokenAmount { token: cainome::cairo_serde::ContractAddress(self.collateral.address), - amount: I129::cairo_deserialize(&[Felt::ZERO], 0)?, + amount: I129 { mag: 0, sign: true }, }; // As mentionned before the route is inverted for precision purpose let liquidate_route: Vec = Position::get_ekubo_route( - String::from("0"), + String::from("10"), // TODO: Investigate the behavior of this value with the Vesu Liquidate contract self.debt.name.clone(), self.collateral.name.clone(), ) .await?; - let liquidate_limit: u128 = u128::MAX; let withdraw_route: Vec = Position::get_ekubo_route( - String::from("0"), + String::from("10"), // TODO: Investigate the behavior of this value with the Vesu Liquidate contract self.debt.name.clone(), String::from("usdc"), ) .await?; - let withdraw_limit: u128 = u128::MAX; let liquidate_contract = Liquidate::new(liquidate_contract, account.0.clone()); let liquidate_swap = Swap { route: liquidate_route, token_amount: liquidate_token, - limit_amount: liquidate_limit, + limit_amount: u128::MAX, }; let withdraw_swap = Swap { route: withdraw_route, token_amount: withdraw_token, - limit_amount: withdraw_limit, + limit_amount: u128::MAX, }; - let min_col_to_retrieve: [u8; 32] = minimum_collateral_to_retrieve - .as_bigint_and_exponent() - .0 - .to_bytes_be() - .1 - .try_into() - .expect("failed to parse min col to retrieve"); - - let debt_to_repay: [u8; 32] = amount_to_liquidate - .as_bigint_and_exponent() - .0 - .to_bytes_be() - .1 - .try_into() - .expect("failed to parse min col to retrieve"); + let min_col_to_retrieve = big_decimal_to_felt(minimum_collateral_to_retrieve); + let debt_to_repay = big_decimal_to_felt(amount_to_liquidate); let liquidate_params = LiquidateParams { pool_id: self.pool_id, @@ -327,11 +386,11 @@ impl Position { user: cainome::cairo_serde::ContractAddress(self.user_address), recipient: cainome::cairo_serde::ContractAddress(account.account_address()), min_collateral_to_receive: cainome::cairo_serde::U256::from_bytes_be( - &min_col_to_retrieve, + &min_col_to_retrieve.to_bytes_be(), ), liquidate_swap, withdraw_swap, - debt_to_repay: cainome::cairo_serde::U256::from_bytes_be(&debt_to_repay), + debt_to_repay: cainome::cairo_serde::U256::from_bytes_be(&debt_to_repay.to_bytes_be()), }; let liquidate_call = liquidate_contract.liquidate_getcall(&liquidate_params); diff --git a/src/utils/conversions.rs b/src/utils/conversions.rs index 6486d72..d8217aa 100644 --- a/src/utils/conversions.rs +++ b/src/utils/conversions.rs @@ -22,8 +22,12 @@ pub fn apibara_field_as_felt(value: &FieldElement) -> Felt { /// Converts a BigDecimal to a U256. pub fn big_decimal_to_u256(value: BigDecimal) -> U256 { + U256::from(big_decimal_to_felt(value)) +} + +pub fn big_decimal_to_felt(value: BigDecimal) -> Felt { let (amount, _): (BigInt, _) = value.as_bigint_and_exponent(); - U256::from(Felt::from(amount.clone())) + Felt::from(amount.clone()) } #[cfg(test)]