diff --git a/src/evm/protocol/uniswap_v3/state.rs b/src/evm/protocol/uniswap_v3/state.rs index b8b18d6d..0ff45138 100644 --- a/src/evm/protocol/uniswap_v3/state.rs +++ b/src/evm/protocol/uniswap_v3/state.rs @@ -5,13 +5,13 @@ use num_bigint::BigUint; use tracing::trace; use tycho_core::{dto::ProtocolStateDelta, Bytes}; -use super::{enums::FeeAmount, tycho_decoder::i24_be_bytes_to_i32}; +use super::enums::FeeAmount; use crate::{ evm::protocol::{ safe_math::{safe_add_u256, safe_sub_u256}, u256_num::u256_to_biguint, utils::uniswap::{ - liquidity_math, + i24_be_bytes_to_i32, liquidity_math, sqrt_price_math::sqrt_price_q96_to_f64, swap_math, tick_list::{TickInfo, TickList, TickListErrorKind}, diff --git a/src/evm/protocol/uniswap_v3/tycho_decoder.rs b/src/evm/protocol/uniswap_v3/tycho_decoder.rs index 609770d5..b3632f8d 100644 --- a/src/evm/protocol/uniswap_v3/tycho_decoder.rs +++ b/src/evm/protocol/uniswap_v3/tycho_decoder.rs @@ -6,7 +6,7 @@ use tycho_core::Bytes; use super::{enums::FeeAmount, state::UniswapV3State}; use crate::{ - evm::protocol::utils::uniswap::tick_list::TickInfo, + evm::protocol::utils::uniswap::{i24_be_bytes_to_i32, tick_list::TickInfo}, models::Token, protocol::{errors::InvalidSnapshotError, models::TryFromWithBlock}, }; @@ -124,31 +124,6 @@ impl TryFromWithBlock for UniswapV3State { } } -/// Converts a slice of bytes representing a big-endian 24-bit signed integer -/// to a 32-bit signed integer. -/// -/// # Arguments -/// * `val` - A reference to a `Bytes` type, which should contain at most three bytes. -/// -/// # Returns -/// * The 32-bit signed integer representation of the input bytes. -pub(crate) fn i24_be_bytes_to_i32(val: &Bytes) -> i32 { - let bytes_slice = val.as_ref(); - let bytes_len = bytes_slice.len(); - let mut result = 0i32; - - for (i, &byte) in bytes_slice.iter().enumerate() { - result |= (byte as i32) << (8 * (bytes_len - 1 - i)); - } - - // If the first byte (most significant byte) has its most significant bit set (0x80), - // perform sign extension for negative numbers. - if bytes_len > 0 && bytes_slice[0] & 0x80 != 0 { - result |= -1i32 << (8 * bytes_len); - } - result -} - #[cfg(test)] mod tests { use std::{collections::HashMap, str::FromStr}; @@ -299,17 +274,4 @@ mod tests { InvalidSnapshotError::ValueError(err) if err == *"Unsupported fee amount" )); } - - #[test] - fn test_i24_be_bytes_to_i32() { - let val = Bytes::from_str("0xfeafc6").unwrap(); - let converted = i24_be_bytes_to_i32(&val); - assert_eq!(converted, -86074); - let val = Bytes::from_str("0x02dd").unwrap(); - let converted = i24_be_bytes_to_i32(&val); - assert_eq!(converted, 733); - let val = Bytes::from_str("0xe2bb").unwrap(); - let converted = i24_be_bytes_to_i32(&val); - assert_eq!(converted, -7493); - } } diff --git a/src/evm/protocol/uniswap_v4/mod.rs b/src/evm/protocol/uniswap_v4/mod.rs index 266c62ac..3d2d1284 100644 --- a/src/evm/protocol/uniswap_v4/mod.rs +++ b/src/evm/protocol/uniswap_v4/mod.rs @@ -1 +1,2 @@ pub mod state; +mod tycho_decoder; diff --git a/src/evm/protocol/uniswap_v4/tycho_decoder.rs b/src/evm/protocol/uniswap_v4/tycho_decoder.rs new file mode 100644 index 00000000..000aea8b --- /dev/null +++ b/src/evm/protocol/uniswap_v4/tycho_decoder.rs @@ -0,0 +1,263 @@ +use std::collections::HashMap; + +use alloy_primitives::U256; +use tycho_client::feed::{synchronizer::ComponentWithState, Header}; +use tycho_core::Bytes; + +use super::state::UniswapV4State; +use crate::{ + evm::protocol::{ + uniswap_v4::state::UniswapV4Fees, + utils::uniswap::{i24_be_bytes_to_i32, tick_list::TickInfo}, + }, + models::Token, + protocol::{errors::InvalidSnapshotError, models::TryFromWithBlock}, +}; + +impl TryFromWithBlock for UniswapV4State { + type Error = InvalidSnapshotError; + + /// Decodes a `ComponentWithState` into a `UniswapV4State`. Errors with a `InvalidSnapshotError` + /// if the snapshot is missing any required attributes. + async fn try_from_with_block( + snapshot: ComponentWithState, + _block: Header, + _all_tokens: &HashMap, + ) -> Result { + let liq = snapshot + .state + .attributes + .get("liquidity") + .ok_or_else(|| InvalidSnapshotError::MissingAttribute("liquidity".to_string()))? + .clone(); + + let liquidity = u128::from(liq); + + let sqrt_price = U256::from_be_slice( + snapshot + .state + .attributes + .get("sqrt_price_x96") + .ok_or_else(|| InvalidSnapshotError::MissingAttribute("sqrt_price".to_string()))?, + ); + + let lp_fee = u32::from( + snapshot + .state + .attributes + .get("fee") + .ok_or_else(|| InvalidSnapshotError::MissingAttribute("fee".to_string()))? + .clone(), + ); + + let zero2one_protocol_fee = u32::from( + snapshot + .state + .attributes + .get("protocol_fees/zero2one") + .ok_or_else(|| { + InvalidSnapshotError::MissingAttribute("protocol_fees/zero2one".to_string()) + })? + .clone(), + ); + let one2zero_protocol_fee = u32::from( + snapshot + .state + .attributes + .get("protocol_fees/one2zero") + .ok_or_else(|| { + InvalidSnapshotError::MissingAttribute("protocol_fees/one2zero".to_string()) + })? + .clone(), + ); + + let fees: UniswapV4Fees = + UniswapV4Fees::new(zero2one_protocol_fee, one2zero_protocol_fee, lp_fee); + + let tick_spacing: i32 = i32::from( + snapshot + .component + .static_attributes + .get("tick_spacing") + .ok_or_else(|| InvalidSnapshotError::MissingAttribute("tick_spacing".to_string()))? + .clone(), + ); + + let tick = i24_be_bytes_to_i32( + snapshot + .state + .attributes + .get("tick") + .ok_or_else(|| InvalidSnapshotError::MissingAttribute("tick".to_string()))?, + ); + + let ticks: Result, _> = snapshot + .state + .attributes + .iter() + .filter_map(|(key, value)| { + if key.starts_with("ticks/") { + Some( + key.split('/') + .nth(1)? + .parse::() + .map(|tick_index| TickInfo::new(tick_index, i128::from(value.clone()))) + .map_err(|err| InvalidSnapshotError::ValueError(err.to_string())), + ) + } else { + None + } + }) + .collect(); + + let mut ticks = match ticks { + Ok(ticks) if !ticks.is_empty() => ticks + .into_iter() + .filter(|t| t.net_liquidity != 0) + .collect::>(), + _ => return Err(InvalidSnapshotError::MissingAttribute("tick_liquidities".to_string())), + }; + + ticks.sort_by_key(|tick| tick.index); + + Ok(UniswapV4State::new(liquidity, sqrt_price, fees, tick, tick_spacing, ticks)) + } +} + +#[cfg(test)] +mod tests { + use std::{collections::HashMap, str::FromStr}; + + use chrono::DateTime; + use rstest::rstest; + use tycho_core::{ + dto::{Chain, ChangeType, ProtocolComponent, ResponseProtocolState}, + hex_bytes::Bytes, + }; + + use super::*; + + fn usv4_component() -> ProtocolComponent { + let creation_time = DateTime::from_timestamp(1622526000, 0) + .unwrap() + .naive_utc(); + + // Add a static attribute "tick_spacing" + let mut static_attributes: HashMap = HashMap::new(); + static_attributes + .insert("tick_spacing".to_string(), Bytes::from(60_i32.to_be_bytes().to_vec())); + + ProtocolComponent { + id: "State1".to_string(), + protocol_system: "system1".to_string(), + protocol_type_name: "typename1".to_string(), + chain: Chain::Ethereum, + tokens: Vec::new(), + contract_ids: Vec::new(), + static_attributes, + change: ChangeType::Creation, + creation_tx: Bytes::from_str("0x0000").unwrap(), + created_at: creation_time, + } + } + + fn usv4_attributes() -> HashMap { + vec![ + ("fee".to_string(), Bytes::from(500_i32.to_be_bytes().to_vec())), + ("liquidity".to_string(), Bytes::from(100_u64.to_be_bytes().to_vec())), + ("tick".to_string(), Bytes::from(300_i32.to_be_bytes().to_vec())), + ( + "sqrt_price_x96".to_string(), + Bytes::from( + 79228162514264337593543950336_u128 + .to_be_bytes() + .to_vec(), + ), + ), + ("protocol_fees/zero2one".to_string(), Bytes::from(0_u32.to_be_bytes().to_vec())), + ("protocol_fees/one2zero".to_string(), Bytes::from(0_u32.to_be_bytes().to_vec())), + ("ticks/60/net_liquidity".to_string(), Bytes::from(400_i128.to_be_bytes().to_vec())), + ] + .into_iter() + .collect::>() + } + fn header() -> Header { + Header { + number: 1, + hash: Bytes::from(vec![0; 32]), + parent_hash: Bytes::from(vec![0; 32]), + revert: false, + } + } + + #[tokio::test] + async fn test_usv4_try_from() { + let snapshot = ComponentWithState { + state: ResponseProtocolState { + component_id: "State1".to_owned(), + attributes: usv4_attributes(), + balances: HashMap::new(), + }, + component: usv4_component(), + }; + + let result = UniswapV4State::try_from_with_block(snapshot, header(), &HashMap::new()) + .await + .unwrap(); + + let fees = UniswapV4Fees::new(0, 0, 500); + let expected = UniswapV4State::new( + 100, + U256::from(79228162514264337593543950336_u128), + fees, + 300, + 60, + vec![TickInfo::new(60, 400)], + ); + assert_eq!(result, expected); + } + + #[tokio::test] + #[rstest] + #[case::missing_liquidity("liquidity")] + #[case::missing_sqrt_price("sqrt_price")] + #[case::missing_tick("tick")] + #[case::missing_tick_liquidity("tick_liquidities")] + #[case::missing_fee("fee")] + #[case::missing_fee("protocol_fees/one2zero")] + #[case::missing_fee("protocol_fees/zero2one")] + async fn test_usv4_try_from_invalid(#[case] missing_attribute: String) { + // remove missing attribute + let mut attributes = usv4_attributes(); + attributes.remove(&missing_attribute); + + if missing_attribute == "tick_liquidities" { + attributes.remove("ticks/60/net_liquidity"); + } + + if missing_attribute == "sqrt_price" { + attributes.remove("sqrt_price_x96"); + } + + if missing_attribute == "fee" { + attributes.remove("fee"); + } + + let snapshot = ComponentWithState { + state: ResponseProtocolState { + component_id: "State1".to_owned(), + attributes, + balances: HashMap::new(), + }, + component: usv4_component(), + }; + + let result = UniswapV4State::try_from_with_block(snapshot, header(), &HashMap::new()).await; + + assert!(result.is_err()); + assert!(matches!( + result.err().unwrap(), + InvalidSnapshotError::MissingAttribute(attr) if attr == missing_attribute + )); + } +} diff --git a/src/evm/protocol/utils/uniswap/mod.rs b/src/evm/protocol/utils/uniswap/mod.rs index 971e5b73..b6634d48 100644 --- a/src/evm/protocol/utils/uniswap/mod.rs +++ b/src/evm/protocol/utils/uniswap/mod.rs @@ -1,4 +1,5 @@ use alloy_primitives::{I256, U256}; +use tycho_core::Bytes; pub(crate) mod liquidity_math; mod solidity_math; @@ -35,3 +36,50 @@ pub(crate) struct SwapResults { pub(crate) tick: i32, pub(crate) gas_used: U256, } + +/// Converts a slice of bytes representing a big-endian 24-bit signed integer +/// to a 32-bit signed integer. +/// +/// # Arguments +/// * `val` - A reference to a `Bytes` type, which should contain at most three bytes. +/// +/// # Returns +/// * The 32-bit signed integer representation of the input bytes. +pub(crate) fn i24_be_bytes_to_i32(val: &Bytes) -> i32 { + let bytes_slice = val.as_ref(); + let bytes_len = bytes_slice.len(); + let mut result = 0i32; + + for (i, &byte) in bytes_slice.iter().enumerate() { + result |= (byte as i32) << (8 * (bytes_len - 1 - i)); + } + + // If the first byte (most significant byte) has its most significant bit set (0x80), + // perform sign extension for negative numbers. + if bytes_len > 0 && bytes_slice[0] & 0x80 != 0 { + result |= -1i32 << (8 * bytes_len); + } + result +} + +#[cfg(test)] +mod test { + use std::str::FromStr; + + use tycho_core::Bytes; + + use crate::evm::protocol::utils::uniswap::i24_be_bytes_to_i32; + + #[test] + fn test_i24_be_bytes_to_i32() { + let val = Bytes::from_str("0xfeafc6").unwrap(); + let converted = i24_be_bytes_to_i32(&val); + assert_eq!(converted, -86074); + let val = Bytes::from_str("0x02dd").unwrap(); + let converted = i24_be_bytes_to_i32(&val); + assert_eq!(converted, 733); + let val = Bytes::from_str("0xe2bb").unwrap(); + let converted = i24_be_bytes_to_i32(&val); + assert_eq!(converted, -7493); + } +}