diff --git a/.gitignore b/.gitignore index 0fa8e0b0a2..640056bc1e 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,10 @@ local-scripts tests/debug-price-route.json .husky/.git-secrets-installed .claude/worktrees/ + +# Rust / NAPI-RS native addon +native/target/ +native/node_modules/ +native/*.node +native/index.js +native/index.d.ts diff --git a/.npmignore b/.npmignore index bf7285caae..f4b967b0ee 100644 --- a/.npmignore +++ b/.npmignore @@ -1,5 +1,12 @@ **/* !/build/** +!/native/** +# Exclude native build artifacts (rebuilt from source on install) +native/target/** +native/node_modules/** +native/*.node +native/index.js +native/index.d.ts !LICENSE !package.json !README.md \ No newline at end of file diff --git a/native/Cargo.lock b/native/Cargo.lock new file mode 100644 index 0000000000..ef894d9617 --- /dev/null +++ b/native/Cargo.lock @@ -0,0 +1,331 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "ctor" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "ethnum" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca81e6b4777c89fd810c25a4be2b1bd93ea034fbe58e6a75216a34c6b82c539b" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "napi" +version = "2.16.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55740c4ae1d8696773c78fdafd5d0e5fe9bc9f1b071c7ba493ba5c413a9184f3" +dependencies = [ + "bitflags", + "ctor", + "napi-derive", + "napi-sys", + "once_cell", + "serde", + "serde_json", +] + +[[package]] +name = "napi-build" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d376940fd5b723c6893cd1ee3f33abbfd86acb1cd1ec079f3ab04a2a3bc4d3b1" + +[[package]] +name = "napi-derive" +version = "2.16.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cbe2585d8ac223f7d34f13701434b9d5f4eb9c332cccce8dee57ea18ab8ab0c" +dependencies = [ + "cfg-if", + "convert_case", + "napi-derive-backend", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "napi-derive-backend" +version = "1.0.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1639aaa9eeb76e91c6ae66da8ce3e89e921cd3885e99ec85f4abacae72fc91bf" +dependencies = [ + "convert_case", + "once_cell", + "proc-macro2", + "quote", + "regex", + "semver", + "syn", +] + +[[package]] +name = "napi-sys" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "427802e8ec3a734331fec1035594a210ce1ff4dc5bc1950530920ab717964ea3" +dependencies = [ + "libloading", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "v3-math-native" +version = "0.1.0" +dependencies = [ + "ethnum", + "napi", + "napi-build", + "napi-derive", + "rayon", + "serde", + "serde_json", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/native/Cargo.toml b/native/Cargo.toml new file mode 100644 index 0000000000..1b76002e4f --- /dev/null +++ b/native/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "v3-math-native" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +napi = { version = "2", features = ["napi6", "serde-json"] } +napi-derive = "2" +ethnum = "1" +rayon = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[build-dependencies] +napi-build = "2" + +[profile.release] +lto = true +strip = "symbols" diff --git a/native/build.rs b/native/build.rs new file mode 100644 index 0000000000..9fc2367889 --- /dev/null +++ b/native/build.rs @@ -0,0 +1,5 @@ +extern crate napi_build; + +fn main() { + napi_build::setup(); +} diff --git a/native/package-lock.json b/native/package-lock.json new file mode 100644 index 0000000000..8c5f47d5e7 --- /dev/null +++ b/native/package-lock.json @@ -0,0 +1,32 @@ +{ + "name": "@paraswap/v3-math-native", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@paraswap/v3-math-native", + "version": "0.1.0", + "devDependencies": { + "@napi-rs/cli": "^2.18.0" + } + }, + "node_modules/@napi-rs/cli": { + "version": "2.18.4", + "resolved": "https://registry.npmjs.org/@napi-rs/cli/-/cli-2.18.4.tgz", + "integrity": "sha512-SgJeA4df9DE2iAEpr3M2H0OKl/yjtg1BnRI5/JyowS71tUWhrfSu2LT0V3vlHET+g1hBVlrO60PmEXwUEKp8Mg==", + "dev": true, + "license": "MIT", + "bin": { + "napi": "scripts/index.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + } + } +} diff --git a/native/package.json b/native/package.json new file mode 100644 index 0000000000..762946e082 --- /dev/null +++ b/native/package.json @@ -0,0 +1,20 @@ +{ + "name": "@paraswap/v3-math-native", + "version": "0.1.0", + "private": true, + "main": "index.js", + "types": "index.d.ts", + "napi": { + "name": "v3-math-native", + "triples": { + "defaults": true + } + }, + "scripts": { + "build": "napi build --platform --release", + "build:debug": "napi build --platform" + }, + "devDependencies": { + "@napi-rs/cli": "^2.18.0" + } +} diff --git a/native/src/config.rs b/native/src/config.rs new file mode 100644 index 0000000000..561dcfad18 --- /dev/null +++ b/native/src/config.rs @@ -0,0 +1,73 @@ +/// Configures math variant differences between Uniswap V3 forks. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MathVariant { + /// Standard Uniswap V3: feeProtocol is 4-bit (% 16 / >> 4) + UniswapV3, + /// PancakeSwap V3: feeProtocol is 16-bit (% 65536 / >> 16), delta = feeAmount * fp / 10000 + PancakeSwapV3, + /// Solidly V3: No oracle, no feeProtocol, fee from slot0.fee + SolidlyV3, +} + +impl MathVariant { + pub fn from_str(s: &str) -> Self { + match s { + "pancakeswap_v3" => MathVariant::PancakeSwapV3, + "solidly_v3" => MathVariant::SolidlyV3, + _ => MathVariant::UniswapV3, + } + } + + /// Extract the fee protocol value for the given swap direction. + pub fn fee_protocol( + &self, + fee_protocol_raw: ethnum::U256, + zero_for_one: bool, + ) -> ethnum::U256 { + match self { + MathVariant::SolidlyV3 => ethnum::U256::ZERO, // no protocol fee + MathVariant::UniswapV3 => { + if zero_for_one { + fee_protocol_raw % ethnum::U256::from(16u32) + } else { + fee_protocol_raw >> 4 + } + } + MathVariant::PancakeSwapV3 => { + if zero_for_one { + fee_protocol_raw % ethnum::U256::from(65536u32) + } else { + fee_protocol_raw >> 16 + } + } + } + } + + /// Calculate protocol fee delta from fee amount. + /// V3: delta = feeAmount / feeProtocol + /// PancakeSwap: delta = (feeAmount * feeProtocol) / 10000 + pub fn protocol_fee_delta( + &self, + fee_amount: ethnum::U256, + fee_protocol: ethnum::U256, + ) -> ethnum::U256 { + match self { + MathVariant::SolidlyV3 => ethnum::U256::ZERO, + MathVariant::UniswapV3 => fee_amount / fee_protocol, + MathVariant::PancakeSwapV3 => { + (fee_amount * fee_protocol) / ethnum::U256::from(10000u32) + } + } + } + + /// Whether this variant uses oracle observations. + pub fn has_oracle(&self) -> bool { + !matches!(self, MathVariant::SolidlyV3) + } + + /// PancakeSwap V3 and Solidly V3 zero out remaining amounts for BOTH sell and buy at end of cycles. + /// V3 only zeros for BUY. + pub fn zero_remaining_for_sell(&self) -> bool { + matches!(self, MathVariant::SolidlyV3 | MathVariant::PancakeSwapV3) + } +} diff --git a/native/src/lib.rs b/native/src/lib.rs new file mode 100644 index 0000000000..83c49ded49 --- /dev/null +++ b/native/src/lib.rs @@ -0,0 +1,528 @@ +pub mod config; +pub mod math; +pub mod pool_state; +pub mod query_outputs; +pub mod v4_query_outputs; + +use ethnum::{I256, U256}; +use napi::bindgen_prelude::*; +use napi_derive::napi; +use rayon::prelude::*; +use std::collections::HashMap; + +/// Set the number of threads rayon uses for parallel queries. +/// Call once at startup. Defaults to all available cores if not called. +#[napi] +pub fn set_thread_count(n: u32) { + rayon::ThreadPoolBuilder::new() + .num_threads(n as usize) + .build_global() + .ok(); +} + +use config::MathVariant; +use math::oracle::OracleObservation; +use math::tick::TickInfo; +use pool_state::PoolState; + +// ---- NAPI type definitions for JS interop ---- + +#[napi(object)] +pub struct JsTickEntry { + pub key: i32, + pub liquidity_gross: BigInt, + pub liquidity_net: BigInt, +} + +#[napi(object)] +pub struct JsBitmapEntry { + pub key: i32, + pub value: BigInt, +} + +#[napi(object)] +pub struct JsObservationEntry { + pub key: i32, + pub block_timestamp: BigInt, + pub tick_cumulative: BigInt, + pub seconds_per_liquidity_cumulative_x128: BigInt, + pub initialized: bool, +} + +#[napi(object)] +pub struct JsPoolStateInit { + pub variant: String, + pub bitmap_range: i32, + pub block_timestamp: BigInt, + pub tick_spacing: BigInt, + pub fee: BigInt, + pub sqrt_price_x96: BigInt, + pub tick: BigInt, + pub observation_index: i32, + pub observation_cardinality: i32, + pub observation_cardinality_next: i32, + pub fee_protocol: BigInt, + pub liquidity: BigInt, + pub max_liquidity_per_tick: BigInt, + pub start_tick_bitmap: BigInt, + pub lowest_known_tick: BigInt, + pub highest_known_tick: BigInt, + pub tick_bitmap: Vec, + pub ticks: Vec, + pub observations: Vec, +} + +#[napi(object)] +pub struct JsOutputResult { + pub outputs: Vec, + pub tick_counts: Vec, +} + +// ---- Conversion helpers ---- +// napi::bindgen_prelude::BigInt stores { sign_bit: bool, words: Vec } +// words are little-endian u64 limbs. + +fn bigint_to_u256(bi: &BigInt) -> U256 { + let words = &bi.words; + let low = words.first().copied().unwrap_or(0) as u128 + | (words.get(1).copied().unwrap_or(0) as u128) << 64; + let high = words.get(2).copied().unwrap_or(0) as u128 + | (words.get(3).copied().unwrap_or(0) as u128) << 64; + U256::from_words(high, low) +} + +fn bigint_to_i256(bi: &BigInt) -> I256 { + let u = bigint_to_u256(bi); + let val = u.as_i256(); + if bi.sign_bit { + -val + } else { + val + } +} + +fn u256_to_bigint(val: U256) -> BigInt { + let (high, low) = val.into_words(); + let mut words = vec![ + low as u64, + (low >> 64) as u64, + high as u64, + (high >> 64) as u64, + ]; + // Trim trailing zeros for cleaner representation + while words.len() > 1 && *words.last().unwrap() == 0 { + words.pop(); + } + BigInt { + sign_bit: false, + words, + } +} + +// ---- The main NAPI class ---- + +#[napi] +pub struct RustPoolHandle { + state: PoolState, +} + +#[napi] +impl RustPoolHandle { + /// Create a new Rust-owned pool state from JS data. + #[napi(factory)] + pub fn create(init: JsPoolStateInit) -> Result { + let variant = MathVariant::from_str(&init.variant); + + let mut tick_bitmap = HashMap::with_capacity(init.tick_bitmap.len()); + for entry in &init.tick_bitmap { + tick_bitmap.insert(entry.key as i16, bigint_to_u256(&entry.value)); + } + + let mut ticks = HashMap::with_capacity(init.ticks.len()); + for entry in &init.ticks { + ticks.insert( + entry.key, + TickInfo { + liquidity_gross: bigint_to_u256(&entry.liquidity_gross), + liquidity_net: bigint_to_i256(&entry.liquidity_net), + initialized: true, + }, + ); + } + + let mut observations = HashMap::with_capacity(init.observations.len()); + for entry in &init.observations { + observations.insert( + entry.key as u16, + OracleObservation { + block_timestamp: bigint_to_u256(&entry.block_timestamp), + tick_cumulative: bigint_to_i256(&entry.tick_cumulative), + seconds_per_liquidity_cumulative_x128: bigint_to_u256( + &entry.seconds_per_liquidity_cumulative_x128, + ), + initialized: entry.initialized, + }, + ); + } + + let start_tick_bitmap = bigint_to_i256(&init.start_tick_bitmap); + + // bitmap_range is the total half-width (TICK_BITMAP_BUFFER + TICK_BITMAP_TO_USE) + // passed from TS since it varies per DEX and per network. + let start_i16 = start_tick_bitmap.as_i32() as i16; + let range = init.bitmap_range as i16; + let bitmap_range_lower = start_i16 - range; + let bitmap_range_upper = start_i16 + range; + + Ok(Self { + state: PoolState { + block_timestamp: bigint_to_u256(&init.block_timestamp), + tick_spacing: bigint_to_i256(&init.tick_spacing), + fee: bigint_to_u256(&init.fee), + sqrt_price_x96: bigint_to_u256(&init.sqrt_price_x96), + tick: bigint_to_i256(&init.tick), + observation_index: init.observation_index as u16, + observation_cardinality: init.observation_cardinality as u16, + observation_cardinality_next: init.observation_cardinality_next as u16, + fee_protocol: bigint_to_u256(&init.fee_protocol), + liquidity: bigint_to_u256(&init.liquidity), + max_liquidity_per_tick: bigint_to_u256(&init.max_liquidity_per_tick), + tick_bitmap, + ticks, + observations, + start_tick_bitmap, + lowest_known_tick: bigint_to_i256(&init.lowest_known_tick), + highest_known_tick: bigint_to_i256(&init.highest_known_tick), + bitmap_range_lower, + bitmap_range_upper, + variant, + }, + }) + } + + /// HOT PATH: Price N amounts in one call (BigInt version). + /// side: 0 = SELL, 1 = BUY + #[napi] + pub fn query_outputs( + &self, + amounts: Vec, + zero_for_one: bool, + side: u8, + ) -> Result { + let amounts_u256: Vec = amounts.iter().map(|a| bigint_to_u256(a)).collect(); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + query_outputs::query_outputs(&self.state, &amounts_u256, zero_for_one, side) + })); + + match result { + Ok(output) => { + let outputs: Vec = + output.outputs.iter().map(|v| u256_to_bigint(*v)).collect(); + + Ok(JsOutputResult { + outputs, + tick_counts: output.tick_counts, + }) + } + Err(panic_info) => { + let msg = if let Some(s) = panic_info.downcast_ref::<&str>() { + s.to_string() + } else if let Some(s) = panic_info.downcast_ref::() { + s.clone() + } else { + "Unknown panic in query_outputs".to_string() + }; + Err(Error::new(Status::GenericFailure, msg)) + } + } + } + +} + +// ---- Pool Registry: batch parallel queries ---- + +fn build_pool_state(init: &JsPoolStateInit) -> Result { + let variant = MathVariant::from_str(&init.variant); + + let mut tick_bitmap = HashMap::with_capacity(init.tick_bitmap.len()); + for entry in &init.tick_bitmap { + tick_bitmap.insert(entry.key as i16, bigint_to_u256(&entry.value)); + } + + let mut ticks = HashMap::with_capacity(init.ticks.len()); + for entry in &init.ticks { + ticks.insert( + entry.key, + TickInfo { + liquidity_gross: bigint_to_u256(&entry.liquidity_gross), + liquidity_net: bigint_to_i256(&entry.liquidity_net), + initialized: true, + }, + ); + } + + let mut observations = HashMap::with_capacity(init.observations.len()); + for entry in &init.observations { + observations.insert( + entry.key as u16, + OracleObservation { + block_timestamp: bigint_to_u256(&entry.block_timestamp), + tick_cumulative: bigint_to_i256(&entry.tick_cumulative), + seconds_per_liquidity_cumulative_x128: bigint_to_u256( + &entry.seconds_per_liquidity_cumulative_x128, + ), + initialized: entry.initialized, + }, + ); + } + + let start_tick_bitmap = bigint_to_i256(&init.start_tick_bitmap); + let start_i16 = start_tick_bitmap.as_i32() as i16; + let range = init.bitmap_range as i16; + + Ok(PoolState { + block_timestamp: bigint_to_u256(&init.block_timestamp), + tick_spacing: bigint_to_i256(&init.tick_spacing), + fee: bigint_to_u256(&init.fee), + sqrt_price_x96: bigint_to_u256(&init.sqrt_price_x96), + tick: bigint_to_i256(&init.tick), + observation_index: init.observation_index as u16, + observation_cardinality: init.observation_cardinality as u16, + observation_cardinality_next: init.observation_cardinality_next as u16, + fee_protocol: bigint_to_u256(&init.fee_protocol), + liquidity: bigint_to_u256(&init.liquidity), + max_liquidity_per_tick: bigint_to_u256(&init.max_liquidity_per_tick), + tick_bitmap, + ticks, + observations, + start_tick_bitmap, + lowest_known_tick: bigint_to_i256(&init.lowest_known_tick), + highest_known_tick: bigint_to_i256(&init.highest_known_tick), + bitmap_range_lower: start_i16 - range, + bitmap_range_upper: start_i16 + range, + variant, + }) +} + +#[napi(object)] +pub struct JsPoolQueryResult { + pub key: String, + pub outputs: Vec, + pub tick_counts: Vec, +} + +#[napi] +pub struct RustPoolRegistry { + pools: HashMap, +} + +#[napi] +impl RustPoolRegistry { + #[napi(constructor)] + pub fn new() -> Self { + Self { + pools: HashMap::new(), + } + } + + /// Register or update a pool. + #[napi] + pub fn set_pool(&mut self, key: String, init: JsPoolStateInit) -> Result<()> { + let state = build_pool_state(&init)?; + self.pools.insert(key, state); + Ok(()) + } + + /// Remove a pool. + #[napi] + pub fn remove_pool(&mut self, key: String) { + self.pools.remove(&key); + } + + /// Query multiple pools in parallel. Returns results for all registered pools + /// whose keys are in the provided list. + /// Each pool is queried with the SAME amounts and direction. + #[napi] + pub fn query_many( + &self, + keys: Vec, + amounts: Vec, + zero_for_one: bool, + side: u8, + ) -> Result> { + let amounts_u256: Vec = amounts.iter().map(|a| bigint_to_u256(a)).collect(); + + // Collect references to pools that exist + let pool_refs: Vec<(&str, &PoolState)> = keys + .iter() + .filter_map(|k| self.pools.get(k).map(|p| (k.as_str(), p))) + .collect(); + + // Run all pool queries in parallel using rayon + let results: Vec> = pool_refs + .par_iter() + .map(|(key, pool)| { + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + query_outputs::query_outputs(pool, &amounts_u256, zero_for_one, side) + })); + + match result { + Ok(output) => { + let outputs: Vec = + output.outputs.iter().map(|v| u256_to_bigint(*v)).collect(); + Ok(JsPoolQueryResult { + key: key.to_string(), + outputs, + tick_counts: output.tick_counts, + }) + } + Err(_) => Ok(JsPoolQueryResult { + key: key.to_string(), + outputs: vec![], + tick_counts: vec![], + }), + } + }) + .collect(); + + results.into_iter().collect() + } + + #[napi] + pub fn pool_count(&self) -> u32 { + self.pools.len() as u32 + } +} + +// ---- V4 Pool Registry ---- + +#[napi(object)] +pub struct JsV4PoolStateInit { + pub sqrt_price_x96: BigInt, + pub tick: BigInt, + pub protocol_fee: BigInt, + pub lp_fee: BigInt, + pub liquidity: BigInt, + pub tick_spacing: BigInt, + pub fee_growth_global0_x128: BigInt, + pub fee_growth_global1_x128: BigInt, + pub bitmap_range: i32, + pub start_tick_bitmap: BigInt, + pub tick_bitmap: Vec, + pub ticks: Vec, +} + +#[napi(object)] +pub struct JsV4QueryResult { + pub key: String, + pub outputs: Vec, +} + +fn build_v4_pool_state(init: &JsV4PoolStateInit) -> v4_query_outputs::V4PoolState { + let mut tick_bitmap = HashMap::with_capacity(init.tick_bitmap.len()); + for entry in &init.tick_bitmap { + tick_bitmap.insert(entry.key as i16, bigint_to_u256(&entry.value)); + } + + let mut ticks = HashMap::with_capacity(init.ticks.len()); + for entry in &init.ticks { + ticks.insert( + entry.key, + TickInfo { + liquidity_gross: bigint_to_u256(&entry.liquidity_gross), + liquidity_net: bigint_to_i256(&entry.liquidity_net), + initialized: true, + }, + ); + } + + v4_query_outputs::V4PoolState { + sqrt_price_x96: bigint_to_u256(&init.sqrt_price_x96), + tick: bigint_to_i256(&init.tick), + protocol_fee: bigint_to_u256(&init.protocol_fee), + lp_fee: bigint_to_u256(&init.lp_fee), + liquidity: bigint_to_u256(&init.liquidity), + tick_spacing: bigint_to_i256(&init.tick_spacing), + fee_growth_global0_x128: bigint_to_u256(&init.fee_growth_global0_x128), + fee_growth_global1_x128: bigint_to_u256(&init.fee_growth_global1_x128), + tick_bitmap, + ticks, + } +} + +#[napi] +pub struct RustV4PoolRegistry { + pools: HashMap, // state + tickSpacing +} + +#[napi] +impl RustV4PoolRegistry { + #[napi(constructor)] + pub fn new() -> Self { + Self { + pools: HashMap::new(), + } + } + + #[napi] + pub fn set_pool(&mut self, key: String, init: JsV4PoolStateInit) { + let tick_spacing = bigint_to_i256(&init.tick_spacing); + let state = build_v4_pool_state(&init); + self.pools.insert(key, (state, tick_spacing)); + } + + #[napi] + pub fn remove_pool(&mut self, key: String) { + self.pools.remove(&key); + } + + #[napi] + pub fn query_many( + &self, + keys: Vec, + amounts: Vec, + zero_for_one: bool, + side: u8, + ) -> Vec { + let amounts_u256: Vec = amounts.iter().map(|a| bigint_to_u256(a)).collect(); + + let pool_refs: Vec<(&str, &v4_query_outputs::V4PoolState, I256)> = keys + .iter() + .filter_map(|k| { + self.pools + .get(k) + .map(|(p, ts)| (k.as_str(), p, *ts)) + }) + .collect(); + + pool_refs + .par_iter() + .map(|(key, pool, tick_spacing)| { + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + v4_query_outputs::query_outputs(pool, *tick_spacing, &amounts_u256, zero_for_one, side) + })); + + match result { + Ok(outputs) => { + let bigint_outputs: Vec = + outputs.iter().map(|v| u256_to_bigint(*v)).collect(); + JsV4QueryResult { + key: key.to_string(), + outputs: bigint_outputs, + } + } + Err(_) => JsV4QueryResult { + key: key.to_string(), + outputs: vec![], + }, + } + }) + .collect() + } + + #[napi] + pub fn pool_count(&self) -> u32 { + self.pools.len() as u32 + } +} diff --git a/native/src/math/bit_math.rs b/native/src/math/bit_math.rs new file mode 100644 index 0000000000..e3b9d2affb --- /dev/null +++ b/native/src/math/bit_math.rs @@ -0,0 +1,177 @@ +use ethnum::U256; + +/// Returns the index of the most significant bit of the number, +/// where the least significant bit is at index 0 and the most significant bit is at index 255. +/// +/// Panics if x is zero. +pub fn most_significant_bit(x: U256) -> u8 { + assert!(x > U256::ZERO, "x must be > 0"); + let mut x = x; + let mut r: u8 = 0; + + if x >= U256::from_words(1, 0) { + x >>= 128; + r += 128; + } + if x >= U256::from(0x10000000000000000u128) { + x >>= 64; + r += 64; + } + if x >= U256::from(0x100000000u128) { + x >>= 32; + r += 32; + } + if x >= U256::from(0x10000u64) { + x >>= 16; + r += 16; + } + if x >= U256::from(0x100u64) { + x >>= 8; + r += 8; + } + if x >= U256::from(0x10u64) { + x >>= 4; + r += 4; + } + if x >= U256::from(0x4u64) { + x >>= 2; + r += 2; + } + if x >= U256::from(0x2u64) { + r += 1; + } + + r +} + +/// Returns the index of the least significant bit of the number, +/// where the least significant bit is at index 0 and the most significant bit is at index 255. +/// +/// Panics if x is zero. +pub fn least_significant_bit(x: U256) -> u8 { + assert!(x > U256::ZERO, "x must be > 0"); + let mut x = x; + let mut r: u8 = 255; + + let max_uint128: U256 = U256::new(u128::MAX); + let max_uint64: U256 = U256::from(u64::MAX); + let max_uint32: U256 = U256::from(u32::MAX as u64); + let max_uint16: U256 = U256::from(u16::MAX as u64); + let max_uint8: U256 = U256::from(u8::MAX as u64); + + if (x & max_uint128) > U256::ZERO { + r -= 128; + } else { + x >>= 128; + } + if (x & max_uint64) > U256::ZERO { + r -= 64; + } else { + x >>= 64; + } + if (x & max_uint32) > U256::ZERO { + r -= 32; + } else { + x >>= 32; + } + if (x & max_uint16) > U256::ZERO { + r -= 16; + } else { + x >>= 16; + } + if (x & max_uint8) > U256::ZERO { + r -= 8; + } else { + x >>= 8; + } + if (x & U256::from(0xFu64)) > U256::ZERO { + r -= 4; + } else { + x >>= 4; + } + if (x & U256::from(0x3u64)) > U256::ZERO { + r -= 2; + } else { + x >>= 2; + } + if (x & U256::ONE) > U256::ZERO { + r -= 1; + } + + r +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_msb_one() { + assert_eq!(most_significant_bit(U256::ONE), 0); + } + + #[test] + fn test_msb_two() { + assert_eq!(most_significant_bit(U256::from(2u64)), 1); + } + + #[test] + fn test_msb_powers_of_two() { + for i in 0..=255u8 { + let x = U256::ONE << i; + assert_eq!(most_significant_bit(x), i); + } + } + + #[test] + fn test_msb_max() { + assert_eq!(most_significant_bit(U256::MAX), 255); + } + + #[test] + #[should_panic] + fn test_msb_zero_panics() { + most_significant_bit(U256::ZERO); + } + + #[test] + fn test_lsb_one() { + assert_eq!(least_significant_bit(U256::ONE), 0); + } + + #[test] + fn test_lsb_two() { + assert_eq!(least_significant_bit(U256::from(2u64)), 1); + } + + #[test] + fn test_lsb_powers_of_two() { + for i in 0..=255u8 { + let x = U256::ONE << i; + assert_eq!(least_significant_bit(x), i); + } + } + + #[test] + fn test_lsb_max() { + assert_eq!(least_significant_bit(U256::MAX), 0); + } + + #[test] + #[should_panic] + fn test_lsb_zero_panics() { + least_significant_bit(U256::ZERO); + } + + #[test] + fn test_msb_mixed() { + assert_eq!(most_significant_bit(U256::from(10u64)), 3); + assert_eq!(most_significant_bit(U256::from(24u64)), 4); + } + + #[test] + fn test_lsb_mixed() { + assert_eq!(least_significant_bit(U256::from(10u64)), 1); + assert_eq!(least_significant_bit(U256::from(24u64)), 3); + } +} diff --git a/native/src/math/full_math.rs b/native/src/math/full_math.rs new file mode 100644 index 0000000000..6f984da0d4 --- /dev/null +++ b/native/src/math/full_math.rs @@ -0,0 +1,217 @@ +use ethnum::U256; + +/// Calculates floor(a * b / denominator). +/// +/// The TS version uses BigInt which has arbitrary precision, so `a * b` never +/// overflows. We replicate this by widening to 512-bit via two U256 halves. +/// +/// Panics if the result exceeds U256::MAX or denominator is zero. +pub fn mul_div(a: U256, b: U256, denominator: U256) -> U256 { + assert!(denominator > U256::ZERO, "denominator must be > 0"); + let (lo, hi) = widening_mul(a, b); + let (quot, _) = div_512_by_256(lo, hi, denominator); + quot +} + +/// Calculates ceil(a * b / denominator). +/// +/// Panics if the result exceeds U256::MAX or denominator is zero. +pub fn mul_div_rounding_up(a: U256, b: U256, denominator: U256) -> U256 { + assert!(denominator > U256::ZERO, "denominator must be > 0"); + // result = (a * b + denominator - 1) / denominator + let (lo, hi) = widening_mul(a, b); + // add (denominator - 1) to the 512-bit product + let addend = denominator - U256::ONE; + let (lo2, carry) = lo.overflowing_add(addend); + let hi2 = if carry { hi + U256::ONE } else { hi }; + let (quot, _) = div_512_by_256(lo2, hi2, denominator); + quot +} + +/// Returns (lo, hi) such that a * b = hi * 2^256 + lo. +fn widening_mul(a: U256, b: U256) -> (U256, U256) { + let mask128 = (U256::ONE << 128) - U256::ONE; + + let a_lo = a & mask128; + let a_hi = a >> 128; + let b_lo = b & mask128; + let b_hi = b >> 128; + + let p0: U256 = a_lo * b_lo; + let p1: U256 = a_lo * b_hi; + let p2: U256 = a_hi * b_lo; + let p3: U256 = a_hi * b_hi; + + let lo: U256 = p0; + let hi: U256 = p3; + + // Add p1 << 128 + let p1_lo = p1 << 128; + let p1_hi = p1 >> 128; + let (lo, c1) = lo.overflowing_add(p1_lo); + let hi = hi + p1_hi + if c1 { U256::ONE } else { U256::ZERO }; + + // Add p2 << 128 + let p2_lo = p2 << 128; + let p2_hi = p2 >> 128; + let (lo, c2) = lo.overflowing_add(p2_lo); + let hi = hi + p2_hi + if c2 { U256::ONE } else { U256::ZERO }; + + (lo, hi) +} + +/// Divides a 512-bit number (lo + hi * 2^256) by a 256-bit denominator. +/// Returns (quotient, remainder). Panics if quotient overflows U256. +fn div_512_by_256(lo: U256, hi: U256, d: U256) -> (U256, U256) { + assert!(d > U256::ZERO, "division by zero"); + + if hi == U256::ZERO { + return (lo / d, lo % d); + } + + assert!(hi < d, "mul_div result overflows U256"); + + // Split lo into two 128-bit halves and do two rounds of division. + let mask128 = (U256::ONE << 128) - U256::ONE; + let lo_hi = (lo >> 128) & mask128; + let lo_lo = lo & mask128; + + // First round: divide (hi * 2^128 + lo_hi) by d + let (q_hi, r1) = div_384_by_256(lo_hi, hi, d); + + // Second round: divide (r1 * 2^128 + lo_lo) by d + let (q_lo, rem) = div_384_by_256(lo_lo, r1, d); + + let quotient = (q_hi << 128) + q_lo; + (quotient, rem) +} + +/// Divides (hi * 2^128 + lo_128) by d, where hi < d and lo_128 < 2^128. +/// Returns (quotient, remainder). +fn div_384_by_256(lo_128: U256, hi: U256, d: U256) -> (U256, U256) { + let mask128 = (U256::ONE << 128) - U256::ONE; + let hi_upper = hi >> 128; + + if hi_upper == U256::ZERO { + // hi fits in 128 bits, so hi * 2^128 + lo_128 fits in 256 bits + let numerator = (hi << 128) | lo_128; + return (numerator / d, numerator % d); + } + + // hi doesn't fit in 128 bits. Use bit-by-bit long division. + // The quotient fits in at most ~129 bits (since hi < d). + let _ = mask128; + let mut remainder = hi; + let mut quotient = U256::ZERO; + + for i in (0..128).rev() { + let bit = (lo_128 >> i) & U256::ONE; + + // Check if shifting left would overflow + let overflow = remainder >> 255 != U256::ZERO; + remainder = (remainder << 1) | bit; + + if overflow || remainder >= d { + remainder = remainder.wrapping_sub(d); + quotient = quotient | (U256::ONE << i); + } + } + + (quotient, remainder) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_mul_div_simple() { + assert_eq!( + mul_div(U256::from(6u64), U256::from(7u64), U256::from(3u64)), + U256::from(14u64) + ); + } + + #[test] + fn test_mul_div_large() { + assert_eq!( + mul_div(U256::MAX, U256::ONE, U256::ONE), + U256::MAX + ); + } + + #[test] + fn test_mul_div_rounding_up_exact() { + assert_eq!( + mul_div_rounding_up(U256::from(6u64), U256::from(7u64), U256::from(3u64)), + U256::from(14u64) + ); + } + + #[test] + fn test_mul_div_rounding_up_rounds() { + // 5 * 7 / 3 = 35/3 = 11.666... -> ceil = 12 + assert_eq!( + mul_div_rounding_up(U256::from(5u64), U256::from(7u64), U256::from(3u64)), + U256::from(12u64) + ); + } + + #[test] + fn test_mul_div_floor() { + // 5 * 7 / 3 = 35/3 = 11.666... -> floor = 11 + assert_eq!( + mul_div(U256::from(5u64), U256::from(7u64), U256::from(3u64)), + U256::from(11u64) + ); + } + + #[test] + fn test_mul_div_large_product() { + let a = U256::ONE << 200; + let b = U256::ONE << 200; + let d = U256::ONE << 200; + assert_eq!(mul_div(a, b, d), U256::ONE << 200); + } + + #[test] + fn test_mul_div_max_times_max() { + assert_eq!(mul_div(U256::MAX, U256::MAX, U256::MAX), U256::MAX); + } + + #[test] + #[should_panic] + fn test_mul_div_overflow() { + mul_div(U256::MAX, U256::MAX, U256::ONE); + } + + #[test] + fn test_mul_div_rounding_up_large() { + let a = U256::ONE << 128; + let b = U256::ONE << 128; + let d = (U256::ONE << 128) + U256::ONE; + let result = mul_div_rounding_up(a, b, d); + assert!(result > U256::ZERO); + } + + #[test] + fn test_widening_mul_simple() { + let (lo, hi) = widening_mul(U256::from(3u64), U256::from(7u64)); + assert_eq!(lo, U256::from(21u64)); + assert_eq!(hi, U256::ZERO); + } + + #[test] + fn test_widening_mul_large() { + let (lo, hi) = widening_mul(U256::MAX, U256::from(2u64)); + assert_eq!(hi, U256::ONE); + assert_eq!(lo, U256::MAX - U256::ONE); + } + + #[test] + fn test_mul_div_uniswap_style() { + // Test case from Uniswap V3: mulDiv(Q128, Q128, Q128) = Q128 + let q128 = U256::ONE << 128; + assert_eq!(mul_div(q128, q128, q128), q128); + } +} diff --git a/native/src/math/liquidity_math.rs b/native/src/math/liquidity_math.rs new file mode 100644 index 0000000000..b0132bec54 --- /dev/null +++ b/native/src/math/liquidity_math.rs @@ -0,0 +1,72 @@ +use ethnum::{I256, U256}; + +/// Adds a signed liquidity delta to an unsigned liquidity value. +/// +/// In Solidity, when y < 0: +/// z = x - uint128(-y); require(z < x) +/// When y >= 0: +/// z = x + uint128(y); require(z >= x) +/// +/// Panics with "LS" if y < 0 and the subtraction underflows. +/// Panics with "LA" if y >= 0 and the addition overflows. +pub fn add_delta(x: U256, y: I256) -> U256 { + let mask_128: U256 = (U256::ONE << 128) - U256::ONE; + + if y < I256::ZERO { + // _y = BigInt.asUintN(128, -y) + // (-y) is positive I256; reinterpret as U256 and mask to 128 bits + let neg_y_u256 = (-y).as_u256(); + let _y = neg_y_u256 & mask_128; + let z = x.checked_sub(_y).expect("LS"); + assert!(z < x, "LS"); + z + } else { + // _y = BigInt.asUintN(128, y) + let _y = y.as_u256() & mask_128; + let z = x.checked_add(_y).expect("LA"); + assert!(z >= x, "LA"); + z + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_add_positive_delta() { + let x = U256::from(100u64); + let y = I256::new(50); + assert_eq!(add_delta(x, y), U256::from(150u64)); + } + + #[test] + fn test_add_negative_delta() { + let x = U256::from(100u64); + let y = I256::new(-50); + assert_eq!(add_delta(x, y), U256::from(50u64)); + } + + #[test] + fn test_add_zero_delta() { + let x = U256::from(100u64); + let y = I256::ZERO; + assert_eq!(add_delta(x, y), U256::from(100u64)); + } + + #[test] + #[should_panic(expected = "LS")] + fn test_subtract_too_much() { + let x = U256::from(50u64); + let y = I256::new(-100); + add_delta(x, y); + } + + #[test] + #[should_panic(expected = "LA")] + fn test_add_overflow() { + let x = U256::MAX; + let y = I256::new(1); + add_delta(x, y); + } +} diff --git a/native/src/math/mod.rs b/native/src/math/mod.rs new file mode 100644 index 0000000000..0657187412 --- /dev/null +++ b/native/src/math/mod.rs @@ -0,0 +1,10 @@ +pub mod bit_math; +pub mod full_math; +pub mod liquidity_math; +pub mod sqrt_price_math; +pub mod swap_math; +pub mod tick; +pub mod tick_bitmap; +pub mod tick_math; +pub mod unsafe_math; +pub mod oracle; diff --git a/native/src/math/oracle.rs b/native/src/math/oracle.rs new file mode 100644 index 0000000000..4b1a5869bb --- /dev/null +++ b/native/src/math/oracle.rs @@ -0,0 +1,493 @@ +use ethnum::{I256, U256}; +use std::collections::HashMap; + +// 2^160 - 1: hi_128 = 0xFFFFFFFF, lo_128 = u128::MAX +const MASK_160: U256 = U256::from_words(0xFFFF_FFFF, u128::MAX); + +/// An oracle observation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct OracleObservation { + pub block_timestamp: U256, + pub tick_cumulative: I256, + pub seconds_per_liquidity_cumulative_x128: U256, + pub initialized: bool, +} + +impl Default for OracleObservation { + fn default() -> Self { + OracleObservation { + block_timestamp: U256::ZERO, + tick_cumulative: I256::ZERO, + seconds_per_liquidity_cumulative_x128: U256::ZERO, + initialized: false, + } + } +} + +/// Observation candidate pair used in binary search. +pub struct OracleObservationCandidates { + pub before_or_at: OracleObservation, + pub at_or_after: OracleObservation, +} + +/// Transforms a previous observation into a new one, given the time elapsed and the +/// current tick and liquidity values. +/// +/// The `block_timestamp` parameter corresponds to `state.blockTimestamp` in the TS version. +/// In the original TS, `transform` receives `state` and uses `state.blockTimestamp` for the +/// output's blockTimestamp. Here we pass it explicitly as `block_timestamp_state`. +pub fn transform( + last: &OracleObservation, + block_timestamp: U256, + block_timestamp_state: U256, + tick: I256, + liquidity: U256, +) -> OracleObservation { + let delta = block_timestamp - last.block_timestamp; + + // tickCumulative: last.tickCumulative + BigInt.asIntN(56, tick) * delta + let tick_i56 = sign_extend_i56(tick); + let delta_signed = delta.as_i256(); + let tick_cumulative = last.tick_cumulative + tick_i56 * delta_signed; + + // secondsPerLiquidityCumulativeX128: + // last.spl + (BigInt.asUintN(160, delta) << 128) / (liquidity > 0 ? liquidity : 1) + let delta_u160 = delta & MASK_160; + let numerator = delta_u160 << 128; + let denominator = if liquidity > U256::ZERO { + liquidity + } else { + U256::ONE + }; + let seconds_per_liquidity_cumulative_x128 = + last.seconds_per_liquidity_cumulative_x128 + numerator / denominator; + + OracleObservation { + block_timestamp: block_timestamp_state, + tick_cumulative, + seconds_per_liquidity_cumulative_x128, + initialized: true, + } +} + +/// Writes an oracle observation to the array, returning the updated index and cardinality. +/// +/// `block_timestamp_state` is the state's block timestamp (used for comparison and as the +/// new observation's timestamp). +/// +/// Returns `(updated_index, updated_cardinality)`. +pub fn write( + observations: &mut HashMap, + index: u16, + block_timestamp: U256, + block_timestamp_state: U256, + tick: I256, + liquidity: U256, + cardinality: u16, + cardinality_next: u16, +) -> (u16, u16) { + let last = observations + .get(&index) + .copied() + .expect("last observation must exist"); + + // If the block timestamp hasn't changed, no update needed + if last.block_timestamp == block_timestamp_state { + return (index, cardinality); + } + + let cardinality_updated = if cardinality_next > cardinality && index == cardinality - 1 { + cardinality_next + } else { + cardinality + }; + + let index_updated = ((index as u32 + 1) % cardinality_updated as u32) as u16; + + let new_observation = transform(&last, block_timestamp, block_timestamp_state, tick, liquidity); + observations.insert(index_updated, new_observation); + + // In the TS code, if indexUpdated !== index, the old index is deleted + if index_updated != index { + observations.remove(&index); + } + + (index_updated, cardinality_updated) +} + +/// Compares two timestamps with overflow-aware less-than-or-equal. +/// +/// This handles the uint32 overflow case: if both timestamps are <= time, compare normally. +/// Otherwise, adjust the one that hasn't overflowed by adding 2^32. +pub fn lte(time: U256, a: U256, b: U256) -> bool { + if a <= time && b <= time { + return a <= b; + } + + let two_pow_32 = U256::ONE << 32; + let a_adjusted = if a > time { a } else { a + two_pow_32 }; + let b_adjusted = if b > time { b } else { b + two_pow_32 }; + a_adjusted <= b_adjusted +} + +/// Binary search for the observations surrounding a target timestamp. +pub fn binary_search( + observations: &HashMap, + time: U256, + target: U256, + index: u16, + cardinality: u16, +) -> OracleObservationCandidates { + let mut l = ((index as u32 + 1) % cardinality as u32) as u32; + let mut r = l + cardinality as u32 - 1; + + loop { + let i = (l + r) / 2; + let idx = (i % cardinality as u32) as u16; + let before_or_at = observations + .get(&idx) + .copied() + .unwrap_or_default(); + + if !before_or_at.initialized { + l = i + 1; + continue; + } + + let after_idx = ((i + 1) % cardinality as u32) as u16; + let at_or_after = observations + .get(&after_idx) + .copied() + .unwrap_or_default(); + + let target_at_or_after = lte(time, before_or_at.block_timestamp, target); + + if target_at_or_after && lte(time, target, at_or_after.block_timestamp) { + return OracleObservationCandidates { + before_or_at, + at_or_after, + }; + } + + if !target_at_or_after { + r = i - 1; + } else { + l = i + 1; + } + } +} + +/// Returns the observations surrounding a target timestamp. +/// +/// `block_timestamp_state` is the state's block timestamp used for transform. +pub fn get_surrounding_observations( + observations: &HashMap, + time: U256, + target: U256, + block_timestamp_state: U256, + tick: I256, + index: u16, + liquidity: U256, + cardinality: u16, +) -> OracleObservationCandidates { + let before_or_at = observations + .get(&index) + .copied() + .unwrap_or_default(); + + if lte(time, before_or_at.block_timestamp, target) { + if before_or_at.block_timestamp == target { + return OracleObservationCandidates { + before_or_at, + at_or_after: before_or_at, + }; + } else { + let at_or_after = transform( + &before_or_at, + target, + block_timestamp_state, + tick, + liquidity, + ); + return OracleObservationCandidates { + before_or_at, + at_or_after, + }; + } + } + + let oldest_idx = ((index as u32 + 1) % cardinality as u32) as u16; + let mut before_or_at = observations + .get(&oldest_idx) + .copied() + .unwrap_or_default(); + + if !before_or_at.initialized { + before_or_at = observations.get(&0u16).copied().unwrap_or_default(); + } + + assert!( + lte(time, before_or_at.block_timestamp, target), + "OLD" + ); + + binary_search(observations, time, target, index, cardinality) +} + +/// Returns the accumulator values as of `secondsAgo` seconds ago from the given time. +/// +/// `block_timestamp_state` is the state's block timestamp. +/// +/// Returns `(tick_cumulative, seconds_per_liquidity_cumulative_x128)`. +pub fn observe_single( + observations: &HashMap, + time: U256, + seconds_ago: U256, + block_timestamp_state: U256, + tick: I256, + index: u16, + liquidity: U256, + cardinality: u16, +) -> (I256, U256) { + if seconds_ago == U256::ZERO { + let mut last = observations + .get(&index) + .copied() + .unwrap_or_default(); + if last.block_timestamp != time { + last = transform(&last, time, block_timestamp_state, tick, liquidity); + } + return ( + last.tick_cumulative, + last.seconds_per_liquidity_cumulative_x128, + ); + } + + let target = time - seconds_ago; + + let OracleObservationCandidates { + before_or_at, + at_or_after, + } = get_surrounding_observations( + observations, + time, + target, + block_timestamp_state, + tick, + index, + liquidity, + cardinality, + ); + + if target == before_or_at.block_timestamp { + return ( + before_or_at.tick_cumulative, + before_or_at.seconds_per_liquidity_cumulative_x128, + ); + } else if target == at_or_after.block_timestamp { + return ( + at_or_after.tick_cumulative, + at_or_after.seconds_per_liquidity_cumulative_x128, + ); + } else { + let observation_time_delta = + at_or_after.block_timestamp - before_or_at.block_timestamp; + let target_delta = target - before_or_at.block_timestamp; + let observation_time_delta_signed = observation_time_delta.as_i256(); + let target_delta_signed = target_delta.as_i256(); + + let tick_cumulative = before_or_at.tick_cumulative + + ((at_or_after.tick_cumulative - before_or_at.tick_cumulative) + / observation_time_delta_signed) + * target_delta_signed; + + // secondsPerLiquidityCumulativeX128 interpolation: + // beforeOrAt.spl + BigInt.asUintN(160, + // (BigInt.asUintN(256, atOrAfter.spl - beforeOrAt.spl) * targetDelta) / observationTimeDelta + // ) + let spl_diff = at_or_after.seconds_per_liquidity_cumulative_x128 + .wrapping_sub(before_or_at.seconds_per_liquidity_cumulative_x128); + let spl_interpolated = (spl_diff * target_delta) / observation_time_delta; + let spl_masked = spl_interpolated & MASK_160; + let seconds_per_liquidity_cumulative_x128 = + before_or_at.seconds_per_liquidity_cumulative_x128 + spl_masked; + + return (tick_cumulative, seconds_per_liquidity_cumulative_x128); + } +} + +/// Sign-extend a value to signed 56-bit (equivalent to BigInt.asIntN(56, x)). +fn sign_extend_i56(val: I256) -> I256 { + let mask: I256 = (I256::ONE << 56) - I256::ONE; + let masked = val & mask; + if masked & (I256::ONE << 55) != I256::ZERO { + masked | !mask + } else { + masked + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_obs( + block_timestamp: u64, + tick_cumulative: i64, + spl: u64, + initialized: bool, + ) -> OracleObservation { + OracleObservation { + block_timestamp: U256::from(block_timestamp), + tick_cumulative: I256::from(tick_cumulative), + seconds_per_liquidity_cumulative_x128: U256::from(spl), + initialized, + } + } + + #[test] + fn test_lte_both_below_time() { + assert!(lte(U256::from(100u64), U256::from(50u64), U256::from(60u64))); + assert!(!lte(U256::from(100u64), U256::from(60u64), U256::from(50u64))); + } + + #[test] + fn test_lte_equal() { + assert!(lte(U256::from(100u64), U256::from(50u64), U256::from(50u64))); + } + + #[test] + fn test_lte_overflow_case() { + // a is past overflow, b is before overflow + // time = 10, a = 5 (not overflowed), b = 15 (overflowed past time) + // a <= time? yes. b <= time? no. + // a_adjusted = 5 + 2^32, b_adjusted = 15 + // 5 + 2^32 > 15, so a is NOT <= b + assert!(!lte(U256::from(10u64), U256::from(5u64), U256::from(15u64))); + } + + #[test] + fn test_transform_basic() { + let last = make_obs(100, 5000, 10000, true); + let result = transform( + &last, + U256::from(110u64), // blockTimestamp + U256::from(110u64), // state.blockTimestamp + I256::from(50i64), // tick + U256::from(1000u64), // liquidity + ); + assert_eq!(result.block_timestamp, U256::from(110u64)); + // tickCumulative = 5000 + 50 * 10 = 5500 + assert_eq!(result.tick_cumulative, I256::from(5500i64)); + assert!(result.initialized); + } + + #[test] + fn test_transform_zero_liquidity() { + let last = make_obs(100, 0, 0, true); + let result = transform( + &last, + U256::from(110u64), + U256::from(110u64), + I256::from(10i64), + U256::ZERO, // zero liquidity => denominator becomes 1 + ); + // tickCumulative = 0 + 10 * 10 = 100 + assert_eq!(result.tick_cumulative, I256::from(100i64)); + // secondsPerLiquidity = 0 + (10 << 128) / 1 = 10 << 128 + let expected_spl = U256::from(10u64) << 128; + assert_eq!(result.seconds_per_liquidity_cumulative_x128, expected_spl); + } + + #[test] + fn test_write_same_timestamp_noop() { + let mut observations = HashMap::new(); + observations.insert(0u16, make_obs(100, 0, 0, true)); + + let (idx, card) = write( + &mut observations, + 0, // index + U256::from(100u64), // blockTimestamp + U256::from(100u64), // state.blockTimestamp (same) + I256::from(10i64), + U256::from(1000u64), + 1, // cardinality + 1, // cardinalityNext + ); + assert_eq!(idx, 0); + assert_eq!(card, 1); + } + + #[test] + fn test_write_new_observation() { + let mut observations = HashMap::new(); + observations.insert(0u16, make_obs(100, 0, 0, true)); + + let (idx, card) = write( + &mut observations, + 0, + U256::from(110u64), + U256::from(110u64), + I256::from(10i64), + U256::from(1000u64), + 1, + 2, + ); + // cardinality should increase because cardinalityNext > cardinality and index == cardinality - 1 + assert_eq!(card, 2); + assert_eq!(idx, 1); + assert!(observations.get(&1u16).unwrap().initialized); + } + + #[test] + fn test_observe_single_zero_seconds_ago() { + let mut observations = HashMap::new(); + observations.insert(0u16, make_obs(100, 5000, 10000, true)); + + let (tick_cum, spl) = observe_single( + &observations, + U256::from(100u64), + U256::ZERO, + U256::from(100u64), + I256::from(50i64), + 0, + U256::from(1000u64), + 1, + ); + // secondsAgo == 0 and last.blockTimestamp == time, so return last values directly + assert_eq!(tick_cum, I256::from(5000i64)); + assert_eq!(spl, U256::from(10000u64)); + } + + #[test] + fn test_observe_single_transforms_when_timestamp_differs() { + let mut observations = HashMap::new(); + observations.insert(0u16, make_obs(100, 5000, 10000, true)); + + let (tick_cum, _spl) = observe_single( + &observations, + U256::from(110u64), // current time + U256::ZERO, // secondsAgo = 0 + U256::from(110u64), + I256::from(50i64), // current tick + 0, + U256::from(1000u64), + 1, + ); + // Should transform: tickCumulative = 5000 + 50 * 10 = 5500 + assert_eq!(tick_cum, I256::from(5500i64)); + } + + #[test] + fn test_sign_extend_i56_positive() { + let val = I256::from(42i64); + assert_eq!(sign_extend_i56(val), I256::from(42i64)); + } + + #[test] + fn test_sign_extend_i56_negative() { + let val = I256::from(-1i64); + let result = sign_extend_i56(val); + assert_eq!(result, I256::from(-1i64)); + } +} diff --git a/native/src/math/sqrt_price_math.rs b/native/src/math/sqrt_price_math.rs new file mode 100644 index 0000000000..b8dba2084a --- /dev/null +++ b/native/src/math/sqrt_price_math.rs @@ -0,0 +1,272 @@ +use ethnum::{I256, U256}; +use super::full_math; +use super::unsafe_math; + +const RESOLUTION: u32 = 96; +const Q96: U256 = U256::from_words(0, 1u128 << 96); +// 2^160 - 1: hi_128 = 0xFFFFFFFF, lo_128 = u128::MAX +const MAX_UINT160: U256 = U256::from_words(0xFFFFFFFF, u128::MAX); + +/// Truncate to uint160 range. +fn as_uint160(val: U256) -> U256 { + val & MAX_UINT160 +} + +pub fn get_next_sqrt_price_from_amount0_rounding_up( + sqrt_p_x96: U256, + liquidity: U256, + amount: U256, + add: bool, +) -> U256 { + if amount == U256::ZERO { + return sqrt_p_x96; + } + let numerator1 = liquidity << RESOLUTION; + + let product = amount * sqrt_p_x96; + if add { + if product / amount == sqrt_p_x96 { + let denominator = numerator1 + product; + if denominator >= numerator1 { + return as_uint160(full_math::mul_div_rounding_up( + numerator1, + sqrt_p_x96, + denominator, + )); + } + } + as_uint160(unsafe_math::div_rounding_up( + numerator1, + numerator1 / sqrt_p_x96 + amount, + )) + } else { + assert!( + product / amount == sqrt_p_x96 && numerator1 > product, + "product / amount == sqrt_p_x96 && numerator1 > product" + ); + let denominator = numerator1 - product; + as_uint160(full_math::mul_div_rounding_up( + numerator1, + sqrt_p_x96, + denominator, + )) + } +} + +pub fn get_next_sqrt_price_from_amount1_rounding_down( + sqrt_p_x96: U256, + liquidity: U256, + amount: U256, + add: bool, +) -> U256 { + if add { + let quotient = if amount <= MAX_UINT160 { + (amount << RESOLUTION) / liquidity + } else { + full_math::mul_div(amount, Q96, liquidity) + }; + as_uint160(sqrt_p_x96 + quotient) + } else { + let quotient = if amount <= MAX_UINT160 { + unsafe_math::div_rounding_up(amount << RESOLUTION, liquidity) + } else { + full_math::mul_div_rounding_up(amount, Q96, liquidity) + }; + assert!(sqrt_p_x96 > quotient, "sqrt_p_x96 > quotient"); + as_uint160(sqrt_p_x96 - quotient) + } +} + +pub fn get_next_sqrt_price_from_input( + sqrt_p_x96: U256, + liquidity: U256, + amount_in: U256, + zero_for_one: bool, +) -> U256 { + assert!(sqrt_p_x96 > U256::ZERO, "sqrt_p_x96 > 0"); + assert!(liquidity > U256::ZERO, "liquidity > 0"); + + if zero_for_one { + get_next_sqrt_price_from_amount0_rounding_up(sqrt_p_x96, liquidity, amount_in, true) + } else { + get_next_sqrt_price_from_amount1_rounding_down(sqrt_p_x96, liquidity, amount_in, true) + } +} + +pub fn get_next_sqrt_price_from_output( + sqrt_p_x96: U256, + liquidity: U256, + amount_out: U256, + zero_for_one: bool, +) -> U256 { + assert!(sqrt_p_x96 > U256::ZERO, "sqrt_p_x96 > 0"); + assert!(liquidity > U256::ZERO, "liquidity > 0"); + + if zero_for_one { + get_next_sqrt_price_from_amount1_rounding_down(sqrt_p_x96, liquidity, amount_out, false) + } else { + get_next_sqrt_price_from_amount0_rounding_up(sqrt_p_x96, liquidity, amount_out, false) + } +} + +pub fn get_amount0_delta( + sqrt_ratio_a_x96: U256, + sqrt_ratio_b_x96: U256, + liquidity: U256, + round_up: bool, +) -> U256 { + let (sqrt_ratio_a_x96, sqrt_ratio_b_x96) = if sqrt_ratio_a_x96 > sqrt_ratio_b_x96 { + (sqrt_ratio_b_x96, sqrt_ratio_a_x96) + } else { + (sqrt_ratio_a_x96, sqrt_ratio_b_x96) + }; + + let numerator1 = liquidity << RESOLUTION; + let numerator2 = sqrt_ratio_b_x96 - sqrt_ratio_a_x96; + + assert!(sqrt_ratio_a_x96 > U256::ZERO, "sqrt_ratio_a_x96 > 0"); + + if round_up { + unsafe_math::div_rounding_up( + full_math::mul_div_rounding_up(numerator1, numerator2, sqrt_ratio_b_x96), + sqrt_ratio_a_x96, + ) + } else { + full_math::mul_div(numerator1, numerator2, sqrt_ratio_b_x96) / sqrt_ratio_a_x96 + } +} + +pub fn get_amount1_delta( + sqrt_ratio_a_x96: U256, + sqrt_ratio_b_x96: U256, + liquidity: U256, + round_up: bool, +) -> U256 { + let (sqrt_ratio_a_x96, sqrt_ratio_b_x96) = if sqrt_ratio_a_x96 > sqrt_ratio_b_x96 { + (sqrt_ratio_b_x96, sqrt_ratio_a_x96) + } else { + (sqrt_ratio_a_x96, sqrt_ratio_b_x96) + }; + + if round_up { + full_math::mul_div_rounding_up( + liquidity, + sqrt_ratio_b_x96 - sqrt_ratio_a_x96, + Q96, + ) + } else { + full_math::mul_div(liquidity, sqrt_ratio_b_x96 - sqrt_ratio_a_x96, Q96) + } +} + +/// Signed version: _getAmount0DeltaO with signed liquidity. +/// Equivalent to TS SqrtPriceMath._getAmount0DeltaO. +pub fn get_amount0_delta_signed( + sqrt_ratio_a_x96: U256, + sqrt_ratio_b_x96: U256, + liquidity: I256, +) -> I256 { + let mask_128 = (U256::ONE << 128) - U256::ONE; + if liquidity < I256::ZERO { + // BigInt.asUintN(128, -liquidity) + let abs_liq = (-liquidity).as_u256() & mask_128; + let delta = get_amount0_delta(sqrt_ratio_a_x96, sqrt_ratio_b_x96, abs_liq, false); + // -BigInt.asIntN(256, delta) + -(delta.as_i256()) + } else { + // BigInt.asUintN(128, liquidity) + let liq_u = liquidity.as_u256() & mask_128; + let delta = get_amount0_delta(sqrt_ratio_a_x96, sqrt_ratio_b_x96, liq_u, true); + // BigInt.asIntN(256, delta) + delta.as_i256() + } +} + +/// Signed version: _getAmount1DeltaO with signed liquidity. +/// Equivalent to TS SqrtPriceMath._getAmount1DeltaO. +pub fn get_amount1_delta_signed( + sqrt_ratio_a_x96: U256, + sqrt_ratio_b_x96: U256, + liquidity: I256, +) -> I256 { + let mask_128 = (U256::ONE << 128) - U256::ONE; + if liquidity < I256::ZERO { + let abs_liq = (-liquidity).as_u256() & mask_128; + let delta = get_amount1_delta(sqrt_ratio_a_x96, sqrt_ratio_b_x96, abs_liq, false); + -(delta.as_i256()) + } else { + let liq_u = liquidity.as_u256() & mask_128; + let delta = get_amount1_delta(sqrt_ratio_a_x96, sqrt_ratio_b_x96, liq_u, true); + delta.as_i256() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_amount0_delta_basic() { + let sqrt_a = U256::from(79228162514264337593543950336u128); // Q96 * 1 + let sqrt_b = U256::from(158456325028528675187087900672u128); // Q96 * 2 + let liquidity = U256::from(1_000_000u64); + let result = get_amount0_delta(sqrt_a, sqrt_b, liquidity, true); + assert!(result > U256::ZERO); + } + + #[test] + fn test_get_amount1_delta_basic() { + let sqrt_a = U256::from(79228162514264337593543950336u128); // Q96 * 1 + let sqrt_b = U256::from(158456325028528675187087900672u128); // Q96 * 2 + let liquidity = U256::from(1_000_000u64); + let result = get_amount1_delta(sqrt_a, sqrt_b, liquidity, true); + assert!(result > U256::ZERO); + } + + #[test] + fn test_get_next_sqrt_price_from_input_zero_for_one() { + let sqrt_p = U256::from(79228162514264337593543950336u128); + let liquidity = U256::from(1_000_000_000_000u64); + let amount = U256::from(1_000_000u64); + let result = get_next_sqrt_price_from_input(sqrt_p, liquidity, amount, true); + assert!(result > U256::ZERO); + assert!(result <= sqrt_p); + } + + #[test] + fn test_get_next_sqrt_price_from_input_one_for_zero() { + let sqrt_p = U256::from(79228162514264337593543950336u128); + let liquidity = U256::from(1_000_000_000_000u64); + let amount = U256::from(1_000_000u64); + let result = get_next_sqrt_price_from_input(sqrt_p, liquidity, amount, false); + assert!(result >= sqrt_p); + } + + #[test] + fn test_get_amount0_delta_symmetric() { + let sqrt_a = U256::from(79228162514264337593543950336u128); + let sqrt_b = U256::from(158456325028528675187087900672u128); + let liquidity = U256::from(1_000_000u64); + let r1 = get_amount0_delta(sqrt_a, sqrt_b, liquidity, true); + let r2 = get_amount0_delta(sqrt_b, sqrt_a, liquidity, true); + assert_eq!(r1, r2); + } + + #[test] + fn test_get_amount0_delta_signed_positive() { + let sqrt_a = U256::from(79228162514264337593543950336u128); + let sqrt_b = U256::from(158456325028528675187087900672u128); + let liquidity = I256::from(1_000_000i64); + let result = get_amount0_delta_signed(sqrt_a, sqrt_b, liquidity); + assert!(result > I256::ZERO); + } + + #[test] + fn test_get_amount0_delta_signed_negative() { + let sqrt_a = U256::from(79228162514264337593543950336u128); + let sqrt_b = U256::from(158456325028528675187087900672u128); + let liquidity = I256::from(-1_000_000i64); + let result = get_amount0_delta_signed(sqrt_a, sqrt_b, liquidity); + assert!(result < I256::ZERO); + } +} diff --git a/native/src/math/swap_math.rs b/native/src/math/swap_math.rs new file mode 100644 index 0000000000..78d6c6d2cb --- /dev/null +++ b/native/src/math/swap_math.rs @@ -0,0 +1,306 @@ +use ethnum::{I256, U256}; +use super::full_math; +use super::sqrt_price_math; + +/// Result of a single swap step computation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SwapStepResult { + pub sqrt_ratio_next_x96: U256, + pub amount_in: U256, + pub amount_out: U256, + pub fee_amount: U256, +} + +/// BI_POWS[6] = 1_000_000 +const ONE_MILLION: U256 = U256::new(1_000_000u128); + +/// Computes the result of swapping some amount in, or amount out, given the parameters of the swap. +/// +/// The fee, `fee_pips`, is in hundredths of a bip (i.e. 1e-6). +/// +/// `amount_remaining` is treated as an I256: positive means exact-input, negative means exact-output. +pub fn compute_swap_step( + sqrt_ratio_current_x96: U256, + sqrt_ratio_target_x96: U256, + liquidity: U256, + amount_remaining: I256, + fee_pips: U256, +) -> SwapStepResult { + let zero_for_one = sqrt_ratio_current_x96 >= sqrt_ratio_target_x96; + let exact_in = amount_remaining >= I256::ZERO; + + let sqrt_ratio_next_x96; + let mut amount_in; + let mut amount_out; + let fee_amount; + + if exact_in { + // BigInt.asUintN(256, amountRemaining) -- amountRemaining is non-negative here so it's identity + let amount_remaining_u = amount_remaining.as_u256(); + let amount_remaining_less_fee = + full_math::mul_div(amount_remaining_u, ONE_MILLION - fee_pips, ONE_MILLION); + + amount_in = if zero_for_one { + sqrt_price_math::get_amount0_delta( + sqrt_ratio_target_x96, + sqrt_ratio_current_x96, + liquidity, + true, + ) + } else { + sqrt_price_math::get_amount1_delta( + sqrt_ratio_current_x96, + sqrt_ratio_target_x96, + liquidity, + true, + ) + }; + + if amount_remaining_less_fee >= amount_in { + sqrt_ratio_next_x96 = sqrt_ratio_target_x96; + } else { + sqrt_ratio_next_x96 = sqrt_price_math::get_next_sqrt_price_from_input( + sqrt_ratio_current_x96, + liquidity, + amount_remaining_less_fee, + zero_for_one, + ); + } + } else { + // BigInt.asUintN(256, -amountRemaining) -- negate signed, interpret as unsigned + let neg_amount = (-amount_remaining).as_u256(); + + amount_out = if zero_for_one { + sqrt_price_math::get_amount1_delta( + sqrt_ratio_target_x96, + sqrt_ratio_current_x96, + liquidity, + false, + ) + } else { + sqrt_price_math::get_amount0_delta( + sqrt_ratio_current_x96, + sqrt_ratio_target_x96, + liquidity, + false, + ) + }; + + if neg_amount >= amount_out { + sqrt_ratio_next_x96 = sqrt_ratio_target_x96; + } else { + sqrt_ratio_next_x96 = sqrt_price_math::get_next_sqrt_price_from_output( + sqrt_ratio_current_x96, + liquidity, + neg_amount, + zero_for_one, + ); + } + + // Initialize amount_in to 0; it will be set below + amount_in = U256::ZERO; + } + + // Re-initialize for the second half of the function + // We need to track amount_out properly for the !exact_in case + // The TS code re-computes both amount_in and amount_out based on `max` flag + let max = sqrt_ratio_target_x96 == sqrt_ratio_next_x96; + + if exact_in { + // amount_out was not set in exact_in path above, initialize to 0 + amount_out = U256::ZERO; + } else { + amount_out = if zero_for_one { + sqrt_price_math::get_amount1_delta( + sqrt_ratio_target_x96, + sqrt_ratio_current_x96, + liquidity, + false, + ) + } else { + sqrt_price_math::get_amount0_delta( + sqrt_ratio_current_x96, + sqrt_ratio_target_x96, + liquidity, + false, + ) + }; + } + + if zero_for_one { + if !(max && exact_in) { + amount_in = sqrt_price_math::get_amount0_delta( + sqrt_ratio_next_x96, + sqrt_ratio_current_x96, + liquidity, + true, + ); + } + if !(max && !exact_in) { + amount_out = sqrt_price_math::get_amount1_delta( + sqrt_ratio_next_x96, + sqrt_ratio_current_x96, + liquidity, + false, + ); + } + } else { + if !(max && exact_in) { + amount_in = sqrt_price_math::get_amount1_delta( + sqrt_ratio_current_x96, + sqrt_ratio_next_x96, + liquidity, + true, + ); + } + if !(max && !exact_in) { + amount_out = sqrt_price_math::get_amount0_delta( + sqrt_ratio_current_x96, + sqrt_ratio_next_x96, + liquidity, + false, + ); + } + } + + // Cap the output amount to not exceed the remaining output amount + if !exact_in { + let neg_amount = (-amount_remaining).as_u256(); + if amount_out > neg_amount { + amount_out = neg_amount; + } + } + + if exact_in && sqrt_ratio_next_x96 != sqrt_ratio_target_x96 { + // We didn't reach the target, so take the remainder of the maximum input as fee + fee_amount = amount_remaining.as_u256() - amount_in; + } else { + fee_amount = + full_math::mul_div_rounding_up(amount_in, fee_pips, ONE_MILLION - fee_pips); + } + + SwapStepResult { + sqrt_ratio_next_x96, + amount_in, + amount_out, + fee_amount, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Q96 = 2^96 + const Q96: U256 = U256::from_words(0, 1u128 << 96); + + #[test] + fn test_exact_in_zero_for_one() { + // price moves from 2.0 toward 1.0 + let sqrt_current = Q96 * U256::from(2u64); // sqrt(4) * Q96 + let sqrt_target = Q96; // sqrt(1) * Q96 + let liquidity = U256::from(1_000_000_000_000u128); + let amount_remaining = I256::from(1_000_000i64); + let fee_pips = U256::from(3000u64); // 0.3% + + let result = compute_swap_step( + sqrt_current, + sqrt_target, + liquidity, + amount_remaining, + fee_pips, + ); + + assert!(result.sqrt_ratio_next_x96 > U256::ZERO); + assert!(result.sqrt_ratio_next_x96 <= sqrt_current); + assert!(result.amount_in > U256::ZERO); + assert!(result.amount_out > U256::ZERO); + // amount_in + fee_amount should not exceed amount_remaining + assert!(result.amount_in + result.fee_amount <= amount_remaining.as_u256()); + } + + #[test] + fn test_exact_out_zero_for_one() { + let sqrt_current = Q96 * U256::from(2u64); + let sqrt_target = Q96; + let liquidity = U256::from(1_000_000_000_000u128); + let amount_remaining = I256::from(-500_000i64); // exact output + let fee_pips = U256::from(3000u64); + + let result = compute_swap_step( + sqrt_current, + sqrt_target, + liquidity, + amount_remaining, + fee_pips, + ); + + assert!(result.sqrt_ratio_next_x96 > U256::ZERO); + assert!(result.amount_in > U256::ZERO); + assert!(result.amount_out > U256::ZERO); + // amount_out should not exceed requested + assert!(result.amount_out <= U256::from(500_000u64)); + } + + #[test] + fn test_exact_in_one_for_zero() { + let sqrt_current = Q96; + let sqrt_target = Q96 * U256::from(2u64); + let liquidity = U256::from(1_000_000_000_000u128); + let amount_remaining = I256::from(1_000_000i64); + let fee_pips = U256::from(3000u64); + + let result = compute_swap_step( + sqrt_current, + sqrt_target, + liquidity, + amount_remaining, + fee_pips, + ); + + assert!(result.sqrt_ratio_next_x96 >= sqrt_current); + assert!(result.amount_in > U256::ZERO); + assert!(result.amount_out > U256::ZERO); + } + + #[test] + fn test_fee_amount_when_target_not_reached() { + // Very small liquidity so we definitely reach the target + let sqrt_current = Q96 * U256::from(2u64); + let sqrt_target = Q96; + let liquidity = U256::from(100u64); // very small liquidity + let amount_remaining = I256::from(1_000_000_000i64); // large amount + let fee_pips = U256::from(3000u64); + + let result = compute_swap_step( + sqrt_current, + sqrt_target, + liquidity, + amount_remaining, + fee_pips, + ); + + // Should reach the target price + assert_eq!(result.sqrt_ratio_next_x96, sqrt_target); + } + + #[test] + fn test_zero_fee() { + let sqrt_current = Q96 * U256::from(2u64); + let sqrt_target = Q96; + let liquidity = U256::from(1_000_000_000_000u128); + let amount_remaining = I256::from(1_000_000i64); + let fee_pips = U256::ZERO; + + let result = compute_swap_step( + sqrt_current, + sqrt_target, + liquidity, + amount_remaining, + fee_pips, + ); + + // With zero fee, fee_amount should be 0 + assert_eq!(result.fee_amount, U256::ZERO); + } +} diff --git a/native/src/math/tick.rs b/native/src/math/tick.rs new file mode 100644 index 0000000000..32b5734c11 --- /dev/null +++ b/native/src/math/tick.rs @@ -0,0 +1,239 @@ +use ethnum::{I256, U256}; +use std::collections::HashMap; +use super::liquidity_math; + +/// Information stored for each initialized individual tick. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TickInfo { + pub liquidity_gross: U256, + pub liquidity_net: I256, + pub initialized: bool, +} + +impl Default for TickInfo { + fn default() -> Self { + TickInfo { + liquidity_gross: U256::ZERO, + liquidity_net: I256::ZERO, + initialized: false, + } + } +} + +/// Truncate an I256 to signed 128-bit range, matching `BigInt.asIntN(128, x)`. +fn as_int128(val: I256) -> I256 { + // Mask to 128 bits, then sign-extend from bit 127 + let mask: I256 = (I256::ONE << 128) - I256::ONE; + let masked = val & mask; + if masked & (I256::ONE << 127) != I256::ZERO { + masked | !mask + } else { + masked + } +} + +/// Updates a tick and returns whether the tick was flipped from initialized to uninitialized, +/// or vice versa. +/// +/// Parameters: +/// - `ticks`: mutable reference to tick storage +/// - `tick`: the tick to update +/// - `tick_current`: the current tick +/// - `liquidity_delta`: signed liquidity change +/// - `upper`: true if this is the upper tick of a position being modified +/// - `max_liquidity`: maximum liquidity per tick +/// +/// Returns `true` if the tick was flipped (transitioned between zero and non-zero gross liquidity). +pub fn update( + ticks: &mut HashMap, + tick: i32, + _tick_current: I256, + liquidity_delta: I256, + upper: bool, + max_liquidity: U256, +) -> bool { + let info = ticks.entry(tick).or_insert_with(TickInfo::default); + + let liquidity_gross_before = info.liquidity_gross; + let liquidity_gross_after = liquidity_math::add_delta(liquidity_gross_before, liquidity_delta); + + assert!( + liquidity_gross_after <= max_liquidity, + "LO" + ); + + let flipped = (liquidity_gross_after == U256::ZERO) != (liquidity_gross_before == U256::ZERO); + + if liquidity_gross_before == U256::ZERO { + info.initialized = true; + } + + info.liquidity_gross = liquidity_gross_after; + + // info.liquidityNet = upper + // ? BigInt.asIntN(128, BigInt.asIntN(256, info.liquidityNet) - liquidityDelta) + // : BigInt.asIntN(128, BigInt.asIntN(256, info.liquidityNet) + liquidityDelta) + let net_i256 = info.liquidity_net; // already I256 (256-bit signed) + info.liquidity_net = if upper { + as_int128(net_i256 - liquidity_delta) + } else { + as_int128(net_i256 + liquidity_delta) + }; + + flipped +} + +/// Clears tick data. Equivalent to `delete state.ticks[tick]`. +pub fn clear(ticks: &mut HashMap, tick: i32) { + ticks.remove(&tick); +} + +/// Transitions to the next tick as needed by crossing an initialized tick. +/// Returns the `liquidity_net` of the crossed tick. +pub fn cross(ticks: &HashMap, tick: i32) -> I256 { + let info = ticks.get(&tick).expect("tick not found in cross"); + info.liquidity_net +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_update_new_tick() { + let mut ticks = HashMap::new(); + let flipped = update( + &mut ticks, + 100, + I256::from(50i64), + I256::from(1000i64), + false, + U256::from(1_000_000u64), + ); + assert!(flipped); + let info = ticks.get(&100).unwrap(); + assert_eq!(info.liquidity_gross, U256::from(1000u64)); + assert_eq!(info.liquidity_net, I256::from(1000i64)); + assert!(info.initialized); + } + + #[test] + fn test_update_existing_tick() { + let mut ticks = HashMap::new(); + update( + &mut ticks, + 100, + I256::from(50i64), + I256::from(1000i64), + false, + U256::from(1_000_000u64), + ); + let flipped = update( + &mut ticks, + 100, + I256::from(50i64), + I256::from(500i64), + false, + U256::from(1_000_000u64), + ); + assert!(!flipped); // Not flipped because it was already initialized + let info = ticks.get(&100).unwrap(); + assert_eq!(info.liquidity_gross, U256::from(1500u64)); + assert_eq!(info.liquidity_net, I256::from(1500i64)); + } + + #[test] + fn test_update_upper_tick() { + let mut ticks = HashMap::new(); + let flipped = update( + &mut ticks, + 200, + I256::from(50i64), + I256::from(1000i64), + true, + U256::from(1_000_000u64), + ); + assert!(flipped); + let info = ticks.get(&200).unwrap(); + assert_eq!(info.liquidity_gross, U256::from(1000u64)); + // Upper tick: liquidityNet = -liquidityDelta + assert_eq!(info.liquidity_net, I256::from(-1000i64)); + } + + #[test] + fn test_update_removes_liquidity_flips() { + let mut ticks = HashMap::new(); + update( + &mut ticks, + 100, + I256::from(50i64), + I256::from(1000i64), + false, + U256::from(1_000_000u64), + ); + let flipped = update( + &mut ticks, + 100, + I256::from(50i64), + I256::from(-1000i64), + false, + U256::from(1_000_000u64), + ); + assert!(flipped); + let info = ticks.get(&100).unwrap(); + assert_eq!(info.liquidity_gross, U256::ZERO); + assert_eq!(info.liquidity_net, I256::ZERO); + } + + #[test] + #[should_panic(expected = "LO")] + fn test_update_exceeds_max_liquidity() { + let mut ticks = HashMap::new(); + update( + &mut ticks, + 100, + I256::from(50i64), + I256::from(2000i64), + false, + U256::from(1000u64), // max is 1000 + ); + } + + #[test] + fn test_clear() { + let mut ticks = HashMap::new(); + update( + &mut ticks, + 100, + I256::from(50i64), + I256::from(1000i64), + false, + U256::from(1_000_000u64), + ); + assert!(ticks.contains_key(&100)); + clear(&mut ticks, 100); + assert!(!ticks.contains_key(&100)); + } + + #[test] + fn test_cross() { + let mut ticks = HashMap::new(); + update( + &mut ticks, + 100, + I256::from(50i64), + I256::from(1000i64), + false, + U256::from(1_000_000u64), + ); + let net = cross(&ticks, 100); + assert_eq!(net, I256::from(1000i64)); + } + + #[test] + #[should_panic] + fn test_cross_nonexistent_tick() { + let ticks = HashMap::new(); + cross(&ticks, 100); + } +} diff --git a/native/src/math/tick_bitmap.rs b/native/src/math/tick_bitmap.rs new file mode 100644 index 0000000000..1219688c75 --- /dev/null +++ b/native/src/math/tick_bitmap.rs @@ -0,0 +1,312 @@ +use ethnum::{I256, U256}; +use std::collections::HashMap; +use super::bit_math; + +/// Computes the word position and bit position within that word for a given tick. +/// +/// Equivalent to: `[BigInt.asIntN(16, tick >> 8), BigInt.asUintN(8, tick % 256)]` +/// +/// Returns `(word_pos, bit_pos)` where `word_pos` is an i16 and `bit_pos` is a u8. +pub fn position(tick: I256) -> (i16, u8) { + // word_pos = asIntN(16, tick >> 8) + let shifted = tick >> 8u32; + // Take the low 16 bits and sign-extend from bit 15 + let word_pos = shifted.0[0] as i16; + + // bit_pos = asUintN(8, tick % 256) + // BigInt.asUintN(8, x) takes the low 8 bits of the two's complement representation. + let bit_pos = tick.0[0] as u8; + + (word_pos, bit_pos) +} + +/// Flips the tick's initialized state in the bitmap. +/// +/// `tick` must be divisible by `tick_spacing`. +pub fn flip_tick(bitmap: &mut HashMap, tick: I256, tick_spacing: I256) { + assert!( + tick % tick_spacing == I256::ZERO, + "tick % tick_spacing == 0" + ); + let (word_pos, bit_pos) = position(tick / tick_spacing); + let mask = U256::ONE << bit_pos; + + let entry = bitmap.entry(word_pos).or_insert(U256::ZERO); + *entry ^= mask; +} + +/// Returns the next initialized tick within one word of the current tick. +/// +/// `lte` indicates whether we're searching to the left (less-than-or-equal) or right. +/// `is_price_query` controls whether bounds are checked against bitmap range. +/// `bitmap_range` is (lower, upper) inclusive bounds for valid wordPos values. +/// +/// Returns `Ok((next_tick, initialized))` or `Err` if out of bitmap range. +pub fn next_initialized_tick_within_one_word( + bitmap: &HashMap, + tick: I256, + tick_spacing: I256, + lte: bool, + is_price_query: bool, + bitmap_range: Option<(i16, i16)>, +) -> Result<(I256, bool), &'static str> { + let mut compressed = tick / tick_spacing; + if tick < I256::ZERO && tick % tick_spacing != I256::ZERO { + compressed = compressed - I256::ONE; + } + + if lte { + let (word_pos, bit_pos) = position(compressed); + + // Bounds check — mirrors TS isWordPosOut + if is_price_query { + if let Some((lower, upper)) = bitmap_range { + if word_pos < lower || word_pos > upper { + return Err("INVALID_TICK_BIT_MAP_RANGES"); + } + } + } + + let mask = (U256::ONE << bit_pos) - U256::ONE + (U256::ONE << bit_pos); + let tick_bitmap_value = bitmap.get(&word_pos).copied().unwrap_or(U256::ZERO); + let masked = tick_bitmap_value & mask; + + let initialized = masked != U256::ZERO; + let next = if initialized { + let msb = bit_math::most_significant_bit(masked); + let diff = I256::from(bit_pos as i32) - I256::from(msb as i32); + let diff_i24 = sign_extend_i24(diff); + (compressed - diff_i24) * tick_spacing + } else { + let bp = I256::from(bit_pos as i32); + let bp_i24 = sign_extend_i24(bp); + (compressed - bp_i24) * tick_spacing + }; + + Ok((next, initialized)) + } else { + let (word_pos, bit_pos) = position(compressed + I256::ONE); + + // Bounds check + if is_price_query { + if let Some((lower, upper)) = bitmap_range { + if word_pos < lower || word_pos > upper { + return Err("INVALID_TICK_BIT_MAP_RANGES"); + } + } + } + + let mask = !((U256::ONE << bit_pos) - U256::ONE); + let tick_bitmap_value = bitmap.get(&word_pos).copied().unwrap_or(U256::ZERO); + let masked = tick_bitmap_value & mask; + + let initialized = masked != U256::ZERO; + let next = if initialized { + let lsb = bit_math::least_significant_bit(masked); + let diff = I256::from(lsb as i32) - I256::from(bit_pos as i32); + let diff_i24 = sign_extend_i24(diff); + (compressed + I256::ONE + diff_i24) * tick_spacing + } else { + let diff = I256::from(255i32) - I256::from(bit_pos as i32); + let diff_i24 = sign_extend_i24(diff); + (compressed + I256::ONE + diff_i24) * tick_spacing + }; + + Ok((next, initialized)) + } +} + +/// Convenience wrapper without bounds checking (for tests and non-pricing calls). +#[allow(dead_code)] +fn next_initialized_tick_within_one_word_unchecked( + bitmap: &HashMap, + tick: I256, + tick_spacing: I256, + lte: bool, + is_price_query: bool, +) -> (I256, bool) { + next_initialized_tick_within_one_word(bitmap, tick, tick_spacing, lte, is_price_query, None).unwrap() +} + +/// Sign-extend a value to 24-bit signed (equivalent to BigInt.asIntN(24, x)). +fn sign_extend_i24(val: I256) -> I256 { + let mask = I256::new(0x00FFFFFF); + let masked = val & mask; + if masked & I256::new(0x00800000) != I256::ZERO { + masked | !mask + } else { + masked + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_position_zero() { + let (word, bit) = position(I256::ZERO); + assert_eq!(word, 0); + assert_eq!(bit, 0); + } + + #[test] + fn test_position_positive() { + // tick = 256 => word_pos = 256 >> 8 = 1, bit_pos = 256 % 256 = 0 + let (word, bit) = position(I256::from(256i64)); + assert_eq!(word, 1); + assert_eq!(bit, 0); + + // tick = 257 => word_pos = 257 >> 8 = 1, bit_pos = 257 % 256 = 1 + let (word, bit) = position(I256::from(257i64)); + assert_eq!(word, 1); + assert_eq!(bit, 1); + } + + #[test] + fn test_position_negative() { + // tick = -1 => in two's complement, tick >> 8 = -1 => word_pos = -1 + // bit_pos = low 8 bits of -1 = 0xFF = 255 + let (word, bit) = position(I256::from(-1i64)); + assert_eq!(word, -1); + assert_eq!(bit, 255); + + // tick = -256 => tick >> 8 = -1, bit_pos = low 8 bits of -256 = 0 + let (word, bit) = position(I256::from(-256i64)); + assert_eq!(word, -1); + assert_eq!(bit, 0); + } + + #[test] + fn test_flip_tick() { + let mut bitmap = HashMap::new(); + let tick_spacing = I256::ONE; + + // Flip tick 0 + flip_tick(&mut bitmap, I256::ZERO, tick_spacing); + let (word, bit) = position(I256::ZERO); + assert_eq!(*bitmap.get(&word).unwrap() & (U256::ONE << bit), U256::ONE); + + // Flip tick 0 again (should toggle back to 0) + flip_tick(&mut bitmap, I256::ZERO, tick_spacing); + assert_eq!( + *bitmap.get(&word).unwrap() & (U256::ONE << bit), + U256::ZERO + ); + } + + #[test] + fn test_flip_tick_with_spacing() { + let mut bitmap = HashMap::new(); + let tick_spacing = I256::from(60i64); + + flip_tick(&mut bitmap, I256::from(120i64), tick_spacing); + // 120 / 60 = 2, position(2) = (0, 2) + let val = *bitmap.get(&0i16).unwrap_or(&U256::ZERO); + assert_eq!(val & (U256::ONE << 2), U256::from(4u64)); + } + + #[test] + #[should_panic] + fn test_flip_tick_not_aligned() { + let mut bitmap = HashMap::new(); + flip_tick(&mut bitmap, I256::from(1i64), I256::from(60i64)); + } + + #[test] + fn test_next_initialized_tick_lte() { + let mut bitmap = HashMap::new(); + let tick_spacing = I256::ONE; + + // Set tick 10 as initialized + flip_tick(&mut bitmap, I256::from(10i64), tick_spacing); + + // Search from tick 15, going left (lte=true) + let (next, initialized) = next_initialized_tick_within_one_word_unchecked( + &bitmap, + I256::from(15i64), + tick_spacing, + true, + false, + ); + assert!(initialized); + assert_eq!(next, I256::from(10i64)); + } + + #[test] + fn test_next_initialized_tick_gt() { + let mut bitmap = HashMap::new(); + let tick_spacing = I256::ONE; + + // Set tick 20 as initialized + flip_tick(&mut bitmap, I256::from(20i64), tick_spacing); + + // Search from tick 10, going right (lte=false) + let (next, initialized) = next_initialized_tick_within_one_word_unchecked( + &bitmap, + I256::from(10i64), + tick_spacing, + false, + false, + ); + assert!(initialized); + assert_eq!(next, I256::from(20i64)); + } + + #[test] + fn test_next_initialized_tick_not_found_lte() { + let bitmap = HashMap::new(); + let tick_spacing = I256::ONE; + + // No ticks initialized; searching left from tick 100 + let (next, initialized) = next_initialized_tick_within_one_word_unchecked( + &bitmap, + I256::from(100i64), + tick_spacing, + true, + false, + ); + assert!(!initialized); + // Should return the leftmost tick in this word + // compressed = 100, position(100) = (0, 100), next = (100 - 100) * 1 = 0 + assert_eq!(next, I256::ZERO); + } + + #[test] + fn test_next_initialized_tick_not_found_gt() { + let bitmap = HashMap::new(); + let tick_spacing = I256::ONE; + + // No ticks initialized; searching right from tick 0 + let (next, initialized) = next_initialized_tick_within_one_word_unchecked( + &bitmap, + I256::ZERO, + tick_spacing, + false, + false, + ); + assert!(!initialized); + // compressed = 0, position(1) = (0, 1), next = (0 + 1 + (255 - 1)) * 1 = 255 + assert_eq!(next, I256::from(255i64)); + } + + #[test] + fn test_next_initialized_tick_negative_range() { + let mut bitmap = HashMap::new(); + let tick_spacing = I256::ONE; + + // Set tick -10 as initialized + flip_tick(&mut bitmap, I256::from(-10i64), tick_spacing); + + // Search from tick -5, going left + let (next, initialized) = next_initialized_tick_within_one_word_unchecked( + &bitmap, + I256::from(-5i64), + tick_spacing, + true, + false, + ); + assert!(initialized); + assert_eq!(next, I256::from(-10i64)); + } +} diff --git a/native/src/math/tick_math.rs b/native/src/math/tick_math.rs new file mode 100644 index 0000000000..59081c3e2a --- /dev/null +++ b/native/src/math/tick_math.rs @@ -0,0 +1,321 @@ +use ethnum::{I256, U256}; + +pub const MIN_TICK: I256 = I256::new(-887272); +pub const MAX_TICK: I256 = I256::new(887272); +pub const MIN_SQRT_RATIO: U256 = U256::new(4295128739u128); +// 1461446703485210103287273052203988822378723970342 +// = 0xFFFD8963EFD1FC6A506488495D951D5263988D26 +// hi_128 = 0xFFFD8963, lo_128 = 0xEFD1FC6A506488495D951D5263988D26 +pub const MAX_SQRT_RATIO: U256 = + U256::from_words(0xFFFD8963, 0xEFD1FC6A506488495D951D5263988D26); + +// 2^160 - 1 = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF +// hi_128 = 0xFFFFFFFF, lo_128 = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF +const MASK_160: U256 = U256::from_words(0xFFFFFFFF, u128::MAX); + +/// Truncate to uint160 range. +fn as_uint160(val: U256) -> U256 { + val & MASK_160 +} + +/// Calculates sqrt(1.0001^tick) * 2^96. +/// +/// Panics if |tick| > MAX_TICK. +pub fn get_sqrt_ratio_at_tick(tick: I256) -> U256 { + let abs_tick: U256 = if tick < I256::ZERO { + (-tick).as_u256() + } else { + tick.as_u256() + }; + + assert!(abs_tick <= MAX_TICK.as_u256(), "T"); + + let mut ratio: U256 = if (abs_tick & U256::from(0x1u64)) != U256::ZERO { + U256::new(0xfffcb933bd6fad37aa2d162d1a594001u128) + } else { + // 2^128 + U256::from_words(1, 0) + }; + + macro_rules! apply_tick_bit { + ($bit:expr, $factor:expr) => { + if (abs_tick & U256::from($bit as u64)) != U256::ZERO { + ratio = (ratio * U256::new($factor)) >> 128; + } + }; + } + + apply_tick_bit!(0x2, 0xfff97272373d413259a46990580e213au128); + apply_tick_bit!(0x4, 0xfff2e50f5f656932ef12357cf3c7fdccu128); + apply_tick_bit!(0x8, 0xffe5caca7e10e4e61c3624eaa0941cd0u128); + apply_tick_bit!(0x10, 0xffcb9843d60f6159c9db58835c926644u128); + apply_tick_bit!(0x20, 0xff973b41fa98c081472e6896dfb254c0u128); + apply_tick_bit!(0x40, 0xff2ea16466c96a3843ec78b326b52861u128); + apply_tick_bit!(0x80, 0xfe5dee046a99a2a811c461f1969c3053u128); + apply_tick_bit!(0x100, 0xfcbe86c7900a88aedcffc83b479aa3a4u128); + apply_tick_bit!(0x200, 0xf987a7253ac413176f2b074cf7815e54u128); + apply_tick_bit!(0x400, 0xf3392b0822b70005940c7a398e4b70f3u128); + apply_tick_bit!(0x800, 0xe7159475a2c29b7443b29c7fa6e889d9u128); + apply_tick_bit!(0x1000, 0xd097f3bdfd2022b8845ad8f792aa5825u128); + apply_tick_bit!(0x2000, 0xa9f746462d870fdf8a65dc1f90e061e5u128); + apply_tick_bit!(0x4000, 0x70d869a156d2a1b890bb3df62baf32f7u128); + apply_tick_bit!(0x8000, 0x31be135f97d08fd981231505542fcfa6u128); + apply_tick_bit!(0x10000, 0x9aa508b5b7a84e1c677de54f3e99bc9u128); + apply_tick_bit!(0x20000, 0x5d6af8dedb81196699c329225ee604u128); + apply_tick_bit!(0x40000, 0x2216e584f5fa1ea926041bedfe98u128); + apply_tick_bit!(0x80000, 0x48a170391f7dc42444e8fa2u128); + + if tick > I256::ZERO { + ratio = U256::MAX / ratio; + } + + let remainder = ratio % (U256::ONE << 32); + let extra = if remainder == U256::ZERO { + U256::ZERO + } else { + U256::ONE + }; + as_uint160((ratio >> 32) + extra) +} + +/// Calculates the greatest tick value such that getSqrtRatioAtTick(tick) <= ratio. +/// +/// Panics if sqrtPriceX96 < MIN_SQRT_RATIO or sqrtPriceX96 >= MAX_SQRT_RATIO. +pub fn get_tick_at_sqrt_ratio(sqrt_price_x96: U256) -> I256 { + assert!( + sqrt_price_x96 >= MIN_SQRT_RATIO && sqrt_price_x96 < MAX_SQRT_RATIO, + "R" + ); + + let ratio = sqrt_price_x96 << 32; + + let mut r = ratio; + let mut msb = U256::ZERO; + + // _gt helper inline + let gt = |a: U256, b: U256| -> U256 { + if a > b { U256::ONE } else { U256::ZERO } + }; + + let mut f: U256 = + gt(r, U256::new(0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFu128)) << 7; + msb = msb | f; + r = r >> f; + + f = gt(r, U256::from(0xFFFFFFFFFFFFFFFFu128)) << 6; + msb = msb | f; + r = r >> f; + + f = gt(r, U256::from(0xFFFFFFFFu64)) << 5; + msb = msb | f; + r = r >> f; + + f = gt(r, U256::from(0xFFFFu64)) << 4; + msb = msb | f; + r = r >> f; + + f = gt(r, U256::from(0xFFu64)) << 3; + msb = msb | f; + r = r >> f; + + f = gt(r, U256::from(0xFu64)) << 2; + msb = msb | f; + r = r >> f; + + f = gt(r, U256::from(0x3u64)) << 1; + msb = msb | f; + r = r >> f; + + f = gt(r, U256::ONE); + msb = msb | f; + + if msb >= U256::from(128u64) { + r = ratio >> (msb - U256::from(127u64)); + } else { + r = ratio << (U256::from(127u64) - msb); + } + + // log_2 is int256 in Solidity. (int256(msb) - 128) << 64 + let mut log_2: I256 = (msb.as_i256() - I256::from(128i32)) << 64u32; + + // 14 iterations of squaring + macro_rules! log2_step { + ($shift:expr) => { + r = (r * r) >> 127; + f = r >> 128; + // f is 0 or 1 (unsigned), cast to signed for the OR into log_2 + log_2 = log_2 | ((f.as_i256()) << $shift as u32); + r = r >> f; + }; + } + + log2_step!(63); + log2_step!(62); + log2_step!(61); + log2_step!(60); + log2_step!(59); + log2_step!(58); + log2_step!(57); + log2_step!(56); + log2_step!(55); + log2_step!(54); + log2_step!(53); + log2_step!(52); + log2_step!(51); + + // Last iteration (no r >>= f after) + r = (r * r) >> 127; + f = r >> 128; + log_2 = log_2 | ((f.as_i256()) << 50u32); + + // log_sqrt10001 = log_2 * 255738958999603826347141 (signed multiply) + let log_sqrt10001: I256 = log_2 * I256::new(255738958999603826347141i128); + + // tickLow = int24((log_sqrt10001 - 3402992956809132418596140100660247210) >> 128) + let tick_low_raw: I256 = (log_sqrt10001 + - I256::new(3402992956809132418596140100660247210i128)) + >> 128u32; + + // tickHi = int24((log_sqrt10001 + 291339464771989622907027621153398088495) >> 128) + let tick_hi_raw: I256 = (log_sqrt10001 + + U256::new(291339464771989622907027621153398088495u128).as_i256()) + >> 128u32; + + let tick_low = as_int_n_24(tick_low_raw); + let tick_hi = as_int_n_24(tick_hi_raw); + + if tick_low == tick_hi { + tick_low + } else if get_sqrt_ratio_at_tick(tick_hi) <= sqrt_price_x96 { + tick_hi + } else { + tick_low + } +} + +/// Equivalent to BigInt.asIntN(24, x) -- truncate to 24 bits and sign-extend. +fn as_int_n_24(x: I256) -> I256 { + let mask_24 = I256::new((1i128 << 24) - 1); + let truncated = x & mask_24; + let sign_bit = I256::ONE << 23u32; + if (truncated & sign_bit) != I256::ZERO { + truncated | !mask_24 + } else { + truncated + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_constants() { + assert_eq!(MIN_TICK, I256::new(-887272)); + assert_eq!(MAX_TICK, I256::new(887272)); + assert_eq!(MIN_SQRT_RATIO, U256::from(4295128739u64)); + } + + #[test] + fn test_max_sqrt_ratio() { + let expected = + U256::from_words(0xFFFD8963, 0xEFD1FC6A506488495D951D5263988D26); + assert_eq!(MAX_SQRT_RATIO, expected); + } + + #[test] + fn test_get_sqrt_ratio_at_tick_min() { + let result = get_sqrt_ratio_at_tick(MIN_TICK); + assert_eq!(result, MIN_SQRT_RATIO); + } + + #[test] + fn test_get_sqrt_ratio_at_tick_max() { + let result = get_sqrt_ratio_at_tick(MAX_TICK); + assert_eq!(result, MAX_SQRT_RATIO); + } + + #[test] + fn test_get_sqrt_ratio_at_tick_zero() { + let result = get_sqrt_ratio_at_tick(I256::ZERO); + let q96 = U256::ONE << 96; + assert_eq!(result, q96); + } + + #[test] + fn test_get_tick_at_sqrt_ratio_min() { + let result = get_tick_at_sqrt_ratio(MIN_SQRT_RATIO); + assert_eq!(result, MIN_TICK); + } + + #[test] + fn test_get_tick_at_sqrt_ratio_q96() { + let q96 = U256::ONE << 96; + let result = get_tick_at_sqrt_ratio(q96); + assert_eq!(result, I256::ZERO); + } + + #[test] + fn test_roundtrip_positive_tick() { + let tick = I256::from(100i32); + let sqrt_ratio = get_sqrt_ratio_at_tick(tick); + let computed_tick = get_tick_at_sqrt_ratio(sqrt_ratio); + assert_eq!(computed_tick, tick); + } + + #[test] + fn test_roundtrip_negative_tick() { + let tick = I256::from(-100i32); + let sqrt_ratio = get_sqrt_ratio_at_tick(tick); + let computed_tick = get_tick_at_sqrt_ratio(sqrt_ratio); + assert_eq!(computed_tick, tick); + } + + #[test] + fn test_roundtrip_large_positive_tick() { + let tick = I256::new(887270); + let sqrt_ratio = get_sqrt_ratio_at_tick(tick); + let computed_tick = get_tick_at_sqrt_ratio(sqrt_ratio); + assert_eq!(computed_tick, tick); + } + + #[test] + fn test_roundtrip_large_negative_tick() { + let tick = I256::new(-887270); + let sqrt_ratio = get_sqrt_ratio_at_tick(tick); + let computed_tick = get_tick_at_sqrt_ratio(sqrt_ratio); + assert_eq!(computed_tick, tick); + } + + #[test] + #[should_panic(expected = "T")] + fn test_get_sqrt_ratio_at_tick_too_large() { + get_sqrt_ratio_at_tick(MAX_TICK + I256::ONE); + } + + #[test] + #[should_panic(expected = "T")] + fn test_get_sqrt_ratio_at_tick_too_small() { + get_sqrt_ratio_at_tick(MIN_TICK - I256::ONE); + } + + #[test] + #[should_panic(expected = "R")] + fn test_get_tick_at_sqrt_ratio_too_small() { + get_tick_at_sqrt_ratio(MIN_SQRT_RATIO - U256::ONE); + } + + #[test] + #[should_panic(expected = "R")] + fn test_get_tick_at_sqrt_ratio_too_large() { + get_tick_at_sqrt_ratio(MAX_SQRT_RATIO); + } + + #[test] + fn test_as_int_n_24() { + assert_eq!(as_int_n_24(I256::from(100i32)), I256::from(100i32)); + assert_eq!(as_int_n_24(I256::from(-100i32)), I256::from(-100i32)); + assert_eq!(as_int_n_24(I256::new(887272)), I256::new(887272)); + assert_eq!(as_int_n_24(I256::new(-887272)), I256::new(-887272)); + } +} diff --git a/native/src/math/unsafe_math.rs b/native/src/math/unsafe_math.rs new file mode 100644 index 0000000000..00e951c290 --- /dev/null +++ b/native/src/math/unsafe_math.rs @@ -0,0 +1,67 @@ +use ethnum::U256; + +/// Divides x by y, rounding up. Does not check for division by zero. +/// Equivalent to Solidity's `(x + y - 1) / y` with unchecked arithmetic. +pub fn div_rounding_up(x: U256, y: U256) -> U256 { + (x + y - U256::ONE) / y +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_exact_division() { + assert_eq!( + div_rounding_up(U256::from(10u64), U256::from(5u64)), + U256::from(2u64) + ); + } + + #[test] + fn test_rounds_up() { + assert_eq!( + div_rounding_up(U256::from(11u64), U256::from(5u64)), + U256::from(3u64) + ); + } + + #[test] + fn test_one_divided_by_one() { + assert_eq!( + div_rounding_up(U256::ONE, U256::ONE), + U256::ONE + ); + } + + #[test] + fn test_zero_numerator() { + assert_eq!( + div_rounding_up(U256::ZERO, U256::from(5u64)), + U256::ZERO + ); + } + + #[test] + fn test_large_values() { + let _x = U256::MAX; + let _y = U256::from(2u64); + // (MAX + 2 - 1) / 2 -- but MAX + 1 wraps to 0, so this is (0) / 2 = 0 + // Wait, U256 arithmetic here is NOT wrapping. Let's think: + // x + y - 1 = MAX + 2 - 1 = MAX + 1 which overflows. + // But in Solidity "UnsafeMath" this is unchecked. In our TS it uses BigInt (no overflow). + // TS: (MAX + 2 - 1) / 2 = (MAX + 1) / 2 = 2^256 / 2 = 2^255. + // But U256 can't represent 2^256. The TS BigInt can though. + // Actually in the TS, x is already bounded to uint256 values that come from + // other operations, so x + y - 1 wouldn't overflow in practice. + // We skip this edge case test. + } + + #[test] + fn test_rounding_by_one() { + assert_eq!( + div_rounding_up(U256::from(1u64), U256::from(3u64)), + U256::from(1u64) + ); + } +} diff --git a/native/src/pool_state.rs b/native/src/pool_state.rs new file mode 100644 index 0000000000..2522535d1b --- /dev/null +++ b/native/src/pool_state.rs @@ -0,0 +1,41 @@ +use ethnum::{I256, U256}; +use std::collections::HashMap; + +use crate::config::MathVariant; +use crate::math::oracle::OracleObservation; +use crate::math::tick::TickInfo; + +/// Mirrors the TS PoolState type, containing all fields needed for pricing. +/// balance0/balance1 are NOT included — they stay in JS. +#[derive(Debug, Clone)] +pub struct PoolState { + pub block_timestamp: U256, + pub tick_spacing: I256, + pub fee: U256, + + // slot0 + pub sqrt_price_x96: U256, + pub tick: I256, + pub observation_index: u16, + pub observation_cardinality: u16, + pub observation_cardinality_next: u16, + pub fee_protocol: U256, + + pub liquidity: U256, + pub max_liquidity_per_tick: U256, + + pub tick_bitmap: HashMap, + pub ticks: HashMap, + pub observations: HashMap, + + pub start_tick_bitmap: I256, + pub lowest_known_tick: I256, + pub highest_known_tick: I256, + + /// Precomputed bitmap bounds for price queries. + /// wordPos must be within [bitmap_range_lower, bitmap_range_upper] during pricing. + pub bitmap_range_lower: i16, + pub bitmap_range_upper: i16, + + pub variant: MathVariant, +} diff --git a/native/src/query_outputs.rs b/native/src/query_outputs.rs new file mode 100644 index 0000000000..c63972485e --- /dev/null +++ b/native/src/query_outputs.rs @@ -0,0 +1,379 @@ +use ethnum::{I256, U256}; +use std::collections::HashMap; + +use crate::math::liquidity_math; +use crate::math::oracle; +use crate::math::swap_math; +use crate::math::tick; +use crate::math::tick::TickInfo; +use crate::math::tick_bitmap; +use crate::math::tick_math; +use crate::pool_state::PoolState; + +const MAX_PRICING_COMPUTATION_STEPS_ALLOWED: i32 = 128; + +/// 0 = SELL, 1 = BUY (matches SwapSide enum in TS) +pub const SWAP_SIDE_SELL: u8 = 0; + +pub struct OutputResult { + pub outputs: Vec, + pub tick_counts: Vec, +} + +#[derive(Clone)] +struct PriceComputationState { + amount_specified_remaining: I256, + amount_calculated: I256, + sqrt_price_x96: U256, + tick: I256, + protocol_fee: U256, + liquidity: U256, + is_first_cycle_state: bool, +} + +#[derive(Clone)] +struct PriceComputationCache { + liquidity_start: U256, + block_timestamp: U256, + fee_protocol: U256, + seconds_per_liquidity_cumulative_x128: U256, + tick_cumulative: I256, + computed_latest_observation: bool, + tick_count: i32, +} + +struct Slot0Snapshot { + sqrt_price_x96: U256, + tick: I256, + observation_index: u16, + observation_cardinality: u16, +} + +fn price_computation_cycles( + pool: &PoolState, + ticks_copy: &mut HashMap, + slot0_start: &Slot0Snapshot, + state: &mut PriceComputationState, + cache: &mut PriceComputationCache, + sqrt_price_limit_x96: U256, + zero_for_one: bool, + exact_input: bool, + is_sell: bool, +) -> (PriceComputationState, PriceComputationCache) { + let mut latest_full_cycle_state = state.clone(); + + if cache.tick_count == 0 { + cache.tick_count = 1; + } + let mut latest_full_cycle_cache = cache.clone(); + + let mut last_ticks_copy: Option<(i32, TickInfo)> = None; + + let mut i: i32 = 0; + while state.amount_specified_remaining != I256::ZERO + && state.sqrt_price_x96 != sqrt_price_limit_x96 + { + if latest_full_cycle_cache.tick_count + i > MAX_PRICING_COMPUTATION_STEPS_ALLOWED { + state.amount_specified_remaining = I256::ZERO; + state.amount_calculated = I256::ZERO; + break; + } + + let sqrt_price_start_x96 = state.sqrt_price_x96; + + // Find next initialized tick — returns Err if out of bitmap range + let bitmap_result = tick_bitmap::next_initialized_tick_within_one_word( + &pool.tick_bitmap, + state.tick, + pool.tick_spacing, + zero_for_one, + true, // is_price_query + Some((pool.bitmap_range_lower, pool.bitmap_range_upper)), + ); + + let (tick_next_raw, initialized) = match bitmap_result { + Ok(result) => result, + Err(_) => { + // Out of range — zero out remaining + state.amount_specified_remaining = I256::ZERO; + state.amount_calculated = I256::ZERO; + break; + } + }; + + // Clamp to min/max tick + let tick_next = if tick_next_raw < tick_math::MIN_TICK { + tick_math::MIN_TICK + } else if tick_next_raw > tick_math::MAX_TICK { + tick_math::MAX_TICK + } else { + tick_next_raw + }; + + let sqrt_price_next_x96 = tick_math::get_sqrt_ratio_at_tick(tick_next); + + // Determine target price (clamped by limit) + let sqrt_ratio_target = if zero_for_one { + if sqrt_price_next_x96 < sqrt_price_limit_x96 { + sqrt_price_limit_x96 + } else { + sqrt_price_next_x96 + } + } else { + if sqrt_price_next_x96 > sqrt_price_limit_x96 { + sqrt_price_limit_x96 + } else { + sqrt_price_next_x96 + } + }; + + let step_result = swap_math::compute_swap_step( + state.sqrt_price_x96, + sqrt_ratio_target, + state.liquidity, + state.amount_specified_remaining, + pool.fee, + ); + + state.sqrt_price_x96 = step_result.sqrt_ratio_next_x96; + let amount_in = step_result.amount_in; + let amount_out = step_result.amount_out; + let mut fee_amount = step_result.fee_amount; + + if exact_input { + state.amount_specified_remaining -= + amount_in.as_i256() + fee_amount.as_i256(); + state.amount_calculated -= amount_out.as_i256(); + } else { + state.amount_specified_remaining += amount_out.as_i256(); + state.amount_calculated += + amount_in.as_i256() + fee_amount.as_i256(); + } + + if cache.fee_protocol > U256::ZERO { + let delta = pool.variant.protocol_fee_delta(fee_amount, cache.fee_protocol); + fee_amount -= delta; + state.protocol_fee += delta; + } + + if state.sqrt_price_x96 == sqrt_price_next_x96 { + if initialized { + if pool.variant.has_oracle() && !cache.computed_latest_observation { + let (tc, splc) = oracle::observe_single( + &pool.observations, + cache.block_timestamp, + U256::ZERO, + pool.block_timestamp, + slot0_start.tick, + slot0_start.observation_index, + cache.liquidity_start, + slot0_start.observation_cardinality, + ); + cache.tick_cumulative = tc; + cache.seconds_per_liquidity_cumulative_x128 = splc; + cache.computed_latest_observation = true; + } + + if state.amount_specified_remaining == I256::ZERO { + let tick_idx = tick_next.as_i32(); + if let Some(existing) = ticks_copy.get(&tick_idx) { + last_ticks_copy = Some((tick_idx, existing.clone())); + } + } + + let mut liquidity_net = tick::cross(ticks_copy, tick_next.as_i32()); + if zero_for_one { + liquidity_net = -liquidity_net; + } + + state.liquidity = liquidity_math::add_delta(state.liquidity, liquidity_net); + } + + state.tick = if zero_for_one { + tick_next - I256::ONE + } else { + tick_next + }; + } else if state.sqrt_price_x96 != sqrt_price_start_x96 { + state.tick = tick_math::get_tick_at_sqrt_ratio(state.sqrt_price_x96); + } + + if state.amount_specified_remaining != I256::ZERO { + latest_full_cycle_state = state.clone(); + latest_full_cycle_cache = cache.clone(); + } else if let Some((idx, tick_info)) = last_ticks_copy.take() { + ticks_copy.insert(idx, tick_info); + } + + i += 1; + } + + if i > 1 { + latest_full_cycle_cache.tick_count += i - 1; + } + + if state.amount_specified_remaining != I256::ZERO + && (!is_sell || pool.variant.zero_remaining_for_sell()) + { + state.amount_specified_remaining = I256::ZERO; + state.amount_calculated = I256::ZERO; + } + + (latest_full_cycle_state, latest_full_cycle_cache) +} + +/// Main pricing entry point. Equivalent to UniswapV3Math.queryOutputs() in TS. +pub fn query_outputs( + pool: &PoolState, + amounts: &[U256], + zero_for_one: bool, + side: u8, +) -> OutputResult { + let is_sell = side == SWAP_SIDE_SELL; + + let slot0_start = Slot0Snapshot { + sqrt_price_x96: pool.sqrt_price_x96, + tick: pool.tick, + observation_index: pool.observation_index, + observation_cardinality: pool.observation_cardinality, + }; + + let sqrt_price_limit_x96 = if zero_for_one { + tick_math::MIN_SQRT_RATIO + U256::ONE + } else { + tick_math::MAX_SQRT_RATIO - U256::ONE + }; + + let fee_protocol = pool.variant.fee_protocol(pool.fee_protocol, zero_for_one); + + let mut cache = PriceComputationCache { + liquidity_start: pool.liquidity, + block_timestamp: pool.block_timestamp & U256::from(0xFFFFFFFFu32), + fee_protocol, + seconds_per_liquidity_cumulative_x128: U256::ZERO, + tick_cumulative: I256::ZERO, + computed_latest_observation: false, + tick_count: 0, + }; + + let mut state = PriceComputationState { + amount_specified_remaining: I256::ZERO, + amount_calculated: I256::ZERO, + sqrt_price_x96: slot0_start.sqrt_price_x96, + tick: slot0_start.tick, + protocol_fee: U256::ZERO, + liquidity: cache.liquidity_start, + is_first_cycle_state: true, + }; + + // Verify price limit + if zero_for_one { + assert!( + sqrt_price_limit_x96 < slot0_start.sqrt_price_x96 + && sqrt_price_limit_x96 > tick_math::MIN_SQRT_RATIO, + "SPL" + ); + } else { + assert!( + sqrt_price_limit_x96 > slot0_start.sqrt_price_x96 + && sqrt_price_limit_x96 < tick_math::MAX_SQRT_RATIO, + "SPL" + ); + } + + let mut is_out_of_range = false; + let mut previous_amount = I256::ZERO; + + let mut outputs = vec![U256::ZERO; amounts.len()]; + let mut tick_counts = vec![0i32; amounts.len()]; + + // We use a mutable copy of ticks for cross() mutations during pricing + let mut ticks_copy = pool.ticks.clone(); + + for (i, &amount) in amounts.iter().enumerate() { + if amount == U256::ZERO { + outputs[i] = U256::ZERO; + tick_counts[i] = 0; + continue; + } + + // BigInt.asIntN(256, amount) — reinterpret U256 bits as I256 + let amount_as_i256 = amount.as_i256(); + let amount_specified = if is_sell { + amount_as_i256 + } else { + -amount_as_i256 + }; + + if state.is_first_cycle_state { + state.amount_specified_remaining = amount_specified; + state.is_first_cycle_state = false; + } else { + state.amount_specified_remaining = + amount_specified - (previous_amount - state.amount_specified_remaining); + } + + let exact_input = amount_specified > I256::ZERO; + + if !is_out_of_range { + let (latest_full_cycle_state, latest_full_cycle_cache) = price_computation_cycles( + pool, + &mut ticks_copy, + &slot0_start, + &mut state, + &mut cache, + sqrt_price_limit_x96, + zero_for_one, + exact_input, + is_sell, + ); + + if state.amount_specified_remaining == I256::ZERO + && state.amount_calculated == I256::ZERO + { + is_out_of_range = true; + outputs[i] = U256::ZERO; + tick_counts[i] = 0; + continue; + } + + previous_amount = amount_specified; + + let (amount0, amount1) = if zero_for_one == exact_input { + ( + amount_specified - state.amount_specified_remaining, + state.amount_calculated, + ) + } else { + ( + state.amount_calculated, + amount_specified - state.amount_specified_remaining, + ) + }; + + // Restore state to latest full cycle for next amount + state = latest_full_cycle_state; + cache = latest_full_cycle_cache; + + if is_sell { + // output = BigInt.asUintN(256, -(zeroForOne ? amount1 : amount0)) + let neg = -(if zero_for_one { amount1 } else { amount0 }); + outputs[i] = neg.as_u256(); + tick_counts[i] = cache.tick_count; + } else { + // output = BigInt.asUintN(256, zeroForOne ? amount0 : amount1) + let val = if zero_for_one { amount0 } else { amount1 }; + outputs[i] = val.as_u256(); + tick_counts[i] = cache.tick_count; + } + } else { + outputs[i] = U256::ZERO; + tick_counts[i] = 0; + } + } + + OutputResult { + outputs, + tick_counts, + } +} diff --git a/native/src/v4_query_outputs.rs b/native/src/v4_query_outputs.rs new file mode 100644 index 0000000000..23c82a9850 --- /dev/null +++ b/native/src/v4_query_outputs.rs @@ -0,0 +1,255 @@ +use ethnum::{I256, U256}; +use std::collections::HashMap; + +use crate::math::liquidity_math; +use crate::math::swap_math; +use crate::math::tick; +use crate::math::tick::TickInfo; +use crate::math::tick_bitmap; +use crate::math::tick_math; + +const MAX_PRICING_COMPUTATION_STEPS_ALLOWED: i32 = 64; +const PIPS_DENOMINATOR: U256 = U256::new(1_000_000); + +/// V4 pool state — simplified vs V3 (no oracle, different fee model). +#[derive(Debug, Clone)] +pub struct V4PoolState { + pub sqrt_price_x96: U256, + pub tick: I256, + pub protocol_fee: U256, + pub lp_fee: U256, + pub liquidity: U256, + pub tick_spacing: I256, + pub fee_growth_global0_x128: U256, + pub fee_growth_global1_x128: U256, + pub tick_bitmap: HashMap, + pub ticks: HashMap, +} + +// --- ProtocolFeeLibrary --- + +fn get_zero_for_one_fee(protocol_fee: U256) -> U256 { + protocol_fee % U256::new(4096) // lower 12 bits +} + +fn get_one_for_zero_fee(protocol_fee: U256) -> U256 { + protocol_fee >> 12 +} + +fn calculate_swap_fee(protocol_fee: U256, lp_fee: U256) -> U256 { + // protocolFee + lpFee - (protocolFee * lpFee) / PIPS_DENOMINATOR + protocol_fee + lp_fee - (protocol_fee * lp_fee) / PIPS_DENOMINATOR +} + +// --- SwapMath helpers --- + +fn get_sqrt_price_target(zero_for_one: bool, next_price: U256, limit_price: U256) -> U256 { + let cond = if zero_for_one { + next_price < limit_price + } else { + next_price > limit_price + }; + if cond { limit_price } else { next_price } +} + +/// V4 _swap — single amount, returns (amount0, amount1) as I256. +fn swap( + pool: &V4PoolState, + zero_for_one: bool, + amount_specified: I256, + sqrt_price_limit_x96: U256, + tick_spacing: I256, +) -> (I256, I256) { + let protocol_fee = if zero_for_one { + get_zero_for_one_fee(pool.protocol_fee) + } else { + get_one_for_zero_fee(pool.protocol_fee) + }; + + let swap_fee = if protocol_fee == U256::ZERO { + pool.lp_fee + } else { + calculate_swap_fee(protocol_fee, pool.lp_fee) + }; + + // MAX_SWAP_FEE check + if swap_fee >= U256::new(1_000_000) { + assert!(amount_specified < I256::ZERO, "Invalid fee for exact out"); + } + + if amount_specified == I256::ZERO { + return (I256::ZERO, I256::ZERO); + } + + // SPL checks + if zero_for_one { + assert!(sqrt_price_limit_x96 < pool.sqrt_price_x96, "Price limit already exceeded"); + assert!(sqrt_price_limit_x96 > tick_math::MIN_SQRT_RATIO, "Price limit out of bounds"); + } else { + assert!(sqrt_price_limit_x96 > pool.sqrt_price_x96, "Price limit already exceeded"); + assert!(sqrt_price_limit_x96 < tick_math::MAX_SQRT_RATIO, "Price limit out of bounds"); + } + + let mut amount_remaining = amount_specified; + let mut amount_calculated = I256::ZERO; + let mut sqrt_price_x96 = pool.sqrt_price_x96; + let mut current_tick = pool.tick; + let mut liquidity = pool.liquidity; + + let mut counter = 0i32; + while !(amount_remaining == I256::ZERO || sqrt_price_x96 == sqrt_price_limit_x96) + && counter <= MAX_PRICING_COMPUTATION_STEPS_ALLOWED + { + // Find next tick + // V4 has no bitmap range check — it reads through empty words naturally. + let (tick_next_raw, initialized) = tick_bitmap::next_initialized_tick_within_one_word( + &pool.tick_bitmap, + current_tick, + tick_spacing, + zero_for_one, + false, // not a bounded price query + None, // no bitmap range bounds for V4 + ).unwrap(); + + let tick_next = if tick_next_raw <= tick_math::MIN_TICK { + tick_math::MIN_TICK + } else if tick_next_raw >= tick_math::MAX_TICK { + tick_math::MAX_TICK + } else { + tick_next_raw + }; + + let sqrt_price_next_x96 = tick_math::get_sqrt_ratio_at_tick(tick_next); + + let step_start_price = sqrt_price_x96; + let target = get_sqrt_price_target(zero_for_one, sqrt_price_next_x96, sqrt_price_limit_x96); + + // V4 uses opposite sign convention: negative = exactIn. + // V3's compute_swap_step expects positive = exactIn. + // Negate before calling, results (amountIn/amountOut) stay positive. + let step = swap_math::compute_swap_step( + sqrt_price_x96, + target, + liquidity, + -amount_remaining, + swap_fee, + ); + + sqrt_price_x96 = step.sqrt_ratio_next_x96; + + // V4 sign convention: amountSpecified > 0 = exactOut, < 0 = exactIn + if amount_specified > I256::ZERO { + // exactOut + amount_remaining -= step.amount_out.as_i256(); + amount_calculated -= step.amount_in.as_i256() + step.fee_amount.as_i256(); + } else { + // exactIn + amount_remaining += step.amount_in.as_i256() + step.fee_amount.as_i256(); + amount_calculated += step.amount_out.as_i256(); + } + + if sqrt_price_x96 == sqrt_price_next_x96 { + if initialized { + let mut liquidity_net = tick::cross(&pool.ticks, tick_next.as_i32()); + if zero_for_one { + liquidity_net = -liquidity_net; + } + liquidity = liquidity_math::add_delta(liquidity, liquidity_net); + } + current_tick = if zero_for_one { tick_next - I256::ONE } else { tick_next }; + } else if sqrt_price_x96 != step_start_price { + current_tick = tick_math::get_tick_at_sqrt_ratio(sqrt_price_x96); + } + + counter += 1; + } + + if counter >= MAX_PRICING_COMPUTATION_STEPS_ALLOWED { + return (I256::ZERO, I256::ZERO); + } + + if zero_for_one != (amount_specified < I256::ZERO) { + ( + amount_calculated, + amount_specified - amount_remaining, + ) + } else { + ( + amount_specified - amount_remaining, + amount_calculated, + ) + } +} + +/// V4 queryOutputs — processes each amount independently. +/// Returns outputs as U256 (absolute values). +pub fn query_outputs( + pool: &V4PoolState, + tick_spacing: I256, + amounts: &[U256], + zero_for_one: bool, + side: u8, // 0=SELL, 1=BUY +) -> Vec { + let is_sell = side == 0; + + amounts + .iter() + .map(|&amount| { + if amount == U256::ZERO { + return U256::ZERO; + } + + let sqrt_price_limit_x96 = if zero_for_one { + tick_math::MIN_SQRT_RATIO + U256::ONE + } else { + tick_math::MAX_SQRT_RATIO - U256::ONE + }; + + if is_sell { + let amount_specified = -(amount.as_i256()); // exactIn: negative + let (amount0, amount1) = swap( + pool, + zero_for_one, + amount_specified, + sqrt_price_limit_x96, + tick_spacing, + ); + + let amount_specified_actual = if zero_for_one == (amount_specified < I256::ZERO) { + amount0 + } else { + amount1 + }; + + if amount_specified_actual != amount_specified { + return U256::ZERO; + } + + let output = if zero_for_one { amount1 } else { amount0 }; + output.as_u256() + } else { + let amount_specified = amount.as_i256(); // exactOut: positive + let (amount0, amount1) = swap( + pool, + zero_for_one, + amount_specified, + sqrt_price_limit_x96, + tick_spacing, + ); + + let amount_specified_actual = if zero_for_one == (amount_specified < I256::ZERO) { + amount0 + } else { + amount1 + }; + + if amount_specified_actual != amount_specified { + return U256::ZERO; + } + + let output = if zero_for_one { -amount0 } else { -amount1 }; + output.as_u256() + } + }) + .collect() +} diff --git a/package.json b/package.json index 4de43b890f..a576f3164c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@paraswap/dex-lib", - "version": "5.0.6", + "version": "5.0.2-native-dex-math.5", "main": "build/index.js", "types": "build/index.d.ts", "repository": "https://github.com/paraswap/paraswap-dex-lib", @@ -44,6 +44,7 @@ "yargs": "^17.0.1" }, "scripts": { + "postinstall": "cd native && npm install && npm run build || true", "init-integration": "ts-node scripts/dex-integration.ts init", "test-integration": "ts-node scripts/dex-integration.ts test", "build": "pnpm run check:pq && pnpm run check:es && tsc", diff --git a/scripts/bench-all-dexes.ts b/scripts/bench-all-dexes.ts new file mode 100644 index 0000000000..a94436758c --- /dev/null +++ b/scripts/bench-all-dexes.ts @@ -0,0 +1,253 @@ +/* eslint-disable no-console */ +/* + * End-to-end benchmark: getPricesVolume with batch Rust registry + * across UniswapV3, PancakeSwapV3, SolidlyV3, and UniswapV4. + * Compares Rust vs JS (useRust=false) for each DEX. + */ +import * as dotenv from 'dotenv'; +dotenv.config(); +import { Network, SwapSide } from '../src/constants'; +import { DummyDexHelper } from '../src/dex-helper'; +import { UniswapV3 } from '../src/dex/uniswap-v3/uniswap-v3'; +import { PancakeswapV3 } from '../src/dex/pancakeswap-v3/pancakeswap-v3'; +import { SolidlyV3 } from '../src/dex/solidly-v3/solidly-v3'; +import { UniswapV4 } from '../src/dex/uniswap-v4/uniswap-v4'; +import { performance } from 'perf_hooks'; +import { getBigIntPow } from '../src/utils'; + +const RUNS = 1000; + +function stats(m: number[]) { + const s = [...m].sort((a, b) => a - b); + return { + p50: s[Math.floor(s.length * 0.5)].toFixed(3), + p99: s[Math.floor(s.length * 0.99)].toFixed(3), + max: s[s.length - 1].toFixed(3), + }; +} + +async function bench(fn: () => any): Promise> { + const measures: number[] = []; + for (let i = 0; i < RUNS; i++) { + const s = performance.now(); + await fn(); + measures.push(performance.now() - s); + } + return stats(measures); +} + +const USDC = { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + decimals: 6, +}; +const WETH = { + address: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + decimals: 18, +}; + +(async () => { + const dh = new DummyDexHelper(Network.MAINNET); + const bn = await dh.web3Provider.eth.getBlockNumber(); + console.log(`Block: ${bn} | Runs: ${RUNS}\n`); + + const amounts = [ + 0n, + ...Array.from( + { length: 53 }, + (_, i) => getBigIntPow(6) * BigInt((i + 1) * 100), + ), + ]; + + console.log( + 'DEX'.padEnd(16) + + 'pools' + + ' | ' + + 'JS p50'.padStart(8) + + 'JS p99'.padStart(8) + + ' | ' + + 'Rust p50'.padStart(9) + + 'Rust p99'.padStart(9) + + ' | ' + + 'speedup'.padStart(8), + ); + console.log('-'.repeat(80)); + + // --- UniswapV3 --- + { + const dex = new UniswapV3(Network.MAINNET, 'UniswapV3', dh); + // Init with Rust to populate pools + await dex.getPricesVolume(USDC, WETH, amounts, SwapSide.SELL, bn); + const poolCount = + (await dex.getPricesVolume(USDC, WETH, amounts, SwapSide.SELL, bn)) + ?.length ?? 0; + + // JS benchmark (useRust=false) + const js = await bench(() => + dex.getPricesVolume( + USDC, + WETH, + amounts, + SwapSide.SELL, + bn, + undefined, + undefined, + undefined, + false, + ), + ); + // Rust benchmark (useRust=true, default) + const rs = await bench(() => + dex.getPricesVolume(USDC, WETH, amounts, SwapSide.SELL, bn), + ); + const speedup = (parseFloat(js.p50) / parseFloat(rs.p50)).toFixed(1); + console.log( + 'UniswapV3'.padEnd(16) + + `${poolCount}`.padStart(5) + + ' | ' + + js.p50.padStart(8) + + js.p99.padStart(8) + + ' | ' + + rs.p50.padStart(9) + + rs.p99.padStart(9) + + ' | ' + + `${speedup}x`.padStart(8), + ); + } + + // --- PancakeSwapV3 --- + { + const dex = new PancakeswapV3(Network.MAINNET, 'PancakeswapV3', dh); + await dex.getPricesVolume(USDC, WETH, amounts, SwapSide.SELL, bn); + const poolCount = + (await dex.getPricesVolume(USDC, WETH, amounts, SwapSide.SELL, bn)) + ?.length ?? 0; + + const js = await bench(() => + dex.getPricesVolume( + USDC, + WETH, + amounts, + SwapSide.SELL, + bn, + undefined, + undefined, + undefined, + false, + ), + ); + const rs = await bench(() => + dex.getPricesVolume(USDC, WETH, amounts, SwapSide.SELL, bn), + ); + const speedup = (parseFloat(js.p50) / parseFloat(rs.p50)).toFixed(1); + console.log( + 'PancakeSwapV3'.padEnd(16) + + `${poolCount}`.padStart(5) + + ' | ' + + js.p50.padStart(8) + + js.p99.padStart(8) + + ' | ' + + rs.p50.padStart(9) + + rs.p99.padStart(9) + + ' | ' + + `${speedup}x`.padStart(8), + ); + } + + // --- SolidlyV3 --- + { + const dex = new SolidlyV3(Network.MAINNET, 'SolidlyV3', dh); + await dex.getPricesVolume(USDC, WETH, amounts, SwapSide.SELL, bn); + const poolCount = + (await dex.getPricesVolume(USDC, WETH, amounts, SwapSide.SELL, bn)) + ?.length ?? 0; + + const js = await bench(() => + dex.getPricesVolume(USDC, WETH, amounts, SwapSide.SELL, bn), + ); + const rs = await bench(() => + dex.getPricesVolume( + USDC, + WETH, + amounts, + SwapSide.SELL, + bn, + undefined, + undefined, + undefined, + true, + ), + ); + const speedup = (parseFloat(js.p50) / parseFloat(rs.p50)).toFixed(1); + console.log( + 'SolidlyV3'.padEnd(16) + + `${poolCount}`.padStart(5) + + ' | ' + + js.p50.padStart(8) + + js.p99.padStart(8) + + ' | ' + + rs.p50.padStart(9) + + rs.p99.padStart(9) + + ' | ' + + `${speedup}x`.padStart(8), + ); + } + + // --- UniswapV4 --- + { + const dex = new UniswapV4(Network.MAINNET, 'UniswapV4', dh); + await dex.initializePricing(bn); + const ETH = { + address: '0x0000000000000000000000000000000000000000', + decimals: 18, + }; + await dex.getPoolIdentifiers(USDC, WETH, SwapSide.SELL, bn); + await dex.getPoolIdentifiers(USDC, ETH, SwapSide.SELL, bn); + await new Promise(resolve => setTimeout(resolve, 3000)); + + let r = await dex.getPricesVolume(USDC, WETH, amounts, SwapSide.SELL, bn); + if (!r || r.length === 0) { + r = await dex.getPricesVolume(USDC, ETH, amounts, SwapSide.SELL, bn); + } + const poolCount = r?.length ?? 0; + + if (poolCount > 0) { + const js = await bench(() => + dex.getPricesVolume( + USDC, + WETH, + amounts, + SwapSide.SELL, + bn, + undefined, + undefined, + undefined, + false, + ), + ); + const rs = await bench(() => + dex.getPricesVolume(USDC, WETH, amounts, SwapSide.SELL, bn), + ); + const speedup = (parseFloat(js.p50) / parseFloat(rs.p50)).toFixed(1); + console.log( + 'UniswapV4'.padEnd(16) + + `${poolCount}`.padStart(5) + + ' | ' + + js.p50.padStart(8) + + js.p99.padStart(8) + + ' | ' + + rs.p50.padStart(9) + + rs.p99.padStart(9) + + ' | ' + + `${speedup}x`.padStart(8), + ); + } else { + console.log( + 'UniswapV4'.padEnd(16) + + ' 0 | V4 pools need subgraph (not available in DummyDexHelper)', + ); + } + } + + console.log('-'.repeat(80)); + process.exit(0); +})(); diff --git a/src/dex/aave-gsm/config.ts b/src/dex/aave-gsm/config.ts index a08c0ce831..4624310c83 100644 --- a/src/dex/aave-gsm/config.ts +++ b/src/dex/aave-gsm/config.ts @@ -7,8 +7,8 @@ export const AaveGsmConfig: DexConfigMap = { AaveGsm: { [Network.MAINNET]: { POOL: AaveV3Ethereum.POOL.toLowerCase(), - GSM_USDT: '0x535b2f7C20B9C83d70e519cf9991578eF9816B7B'.toLowerCase(), - GSM_USDC: '0xFeeb6FE430B7523fEF2a38327241eE7153779535'.toLowerCase(), + GSM_USDT: '0x882285E62656b9623AF136Ce3078c6BdCc33F5E3'.toLowerCase(), + GSM_USDC: '0x3A3868898305f04beC7FEa77BecFf04C13444112'.toLowerCase(), waEthUSDT: AaveV3Ethereum.ASSETS.USDT.STATA_TOKEN.toLowerCase(), waEthUSDC: AaveV3Ethereum.ASSETS.USDC.STATA_TOKEN.toLowerCase(), GHO: AaveV3Ethereum.ASSETS.GHO.UNDERLYING.toLowerCase(), diff --git a/src/dex/idex.ts b/src/dex/idex.ts index 5760dc31b0..1413e305b9 100644 --- a/src/dex/idex.ts +++ b/src/dex/idex.ts @@ -198,6 +198,7 @@ export interface IDexPricing { // across all integrations, done it like this transferFees?: TransferFeeParams, isFirstSwap?: boolean, + useRust?: boolean, ): Promise | null>; // Returns estimated gas cost for calldata for DEX when used in multiSwap. diff --git a/src/dex/pancakeswap-v3/pancakeswap-v3-pool.ts b/src/dex/pancakeswap-v3/pancakeswap-v3-pool.ts index 0a51a15a44..a6e7266a91 100644 --- a/src/dex/pancakeswap-v3/pancakeswap-v3-pool.ts +++ b/src/dex/pancakeswap-v3/pancakeswap-v3-pool.ts @@ -30,6 +30,10 @@ import { _reduceTicks, } from '../uniswap-v3/contract-math/utils'; import { INACTIVE_POOL_AGE_MS } from './constants'; +import { + registrySetPool, + RustPoolRegistryType, +} from '../uniswap-v3/contract-math/native-bridge'; export class PancakeSwapV3EventPool extends StatefulEventSubscriber { handlers: { @@ -58,6 +62,8 @@ export class PancakeSwapV3EventPool extends StatefulEventSubscriber { public initFailed = false; public initRetryAttemptCount = 0; + public registry: RustPoolRegistryType | null = null; + public readonly feeCodeAsString; constructor( @@ -276,6 +282,9 @@ export class PancakeSwapV3EventPool extends StatefulEventSubscriber { // ); // } super._setState(state, blockNumber); + if (this.registry && state) { + registrySetPool(this.registry, this.name, state, 'pancakeswap_v3', 12); + } } async generateState(blockNumber: number): Promise> { diff --git a/src/dex/pancakeswap-v3/pancakeswap-v3.ts b/src/dex/pancakeswap-v3/pancakeswap-v3.ts index ba7765cb9d..2b989c6ded 100644 --- a/src/dex/pancakeswap-v3/pancakeswap-v3.ts +++ b/src/dex/pancakeswap-v3/pancakeswap-v3.ts @@ -12,6 +12,7 @@ import { NumberAsString, PoolPrices, DexExchangeParam, + TransferFeeParams, } from '../../types'; import { SwapSide, Network, CACHE_PREFIX } from '../../constants'; import * as CALLDATA_GAS_COST from '../../calldata-gas-cost'; @@ -48,6 +49,10 @@ import { PANCAKESWAPV3_TICK_GAS_COST, } from './constants'; import { DeepReadonly } from 'ts-essentials'; +import { + createRegistry, + RustPoolRegistryType, +} from '../uniswap-v3/contract-math/native-bridge'; import { pancakeswapV3Math } from './contract-math/pancakeswap-v3-math'; import { Contract } from 'web3-eth-contract'; import { AbiItem } from 'web3-utils'; @@ -99,6 +104,8 @@ export class PancakeswapV3 readonly hasConstantPriceLargeAmounts = false; readonly needWrapNative = true; + public readonly registry: RustPoolRegistryType | null = createRegistry(); + intervalTask?: NodeJS.Timeout; public static dexKeysWithNetwork: { key: string; networks: Network[] }[] = @@ -153,6 +160,12 @@ export class PancakeswapV3 this.logger, this.onPoolCreatedDeleteFromNonExistingSet, ); + + this.logger.info( + `${dexKey}: native Rust math ${ + this.registry ? 'enabled' : 'not available' + }`, + ); } get supportedFees() { @@ -334,6 +347,7 @@ export class PancakeswapV3 this.config.initHash, this.config.deployer, ); + pool.registry = this.registry; let result: PancakeSwapV3EventPool | null = pool; @@ -605,6 +619,9 @@ export class PancakeswapV3 side: SwapSide, blockNumber: number, limitPools?: string[], + transferFees?: TransferFeeParams, + isFirstSwap?: boolean, + useRust?: boolean, ): Promise> { try { const _srcToken = this.dexHelper.config.wrapETH(srcToken); @@ -723,79 +740,148 @@ export class PancakeswapV3 const zeroForOne = token0 === _srcAddress ? true : false; - const result = await Promise.all( - poolsToUse.poolWithState.map(async (pool, i) => { - const state = states[i]; - - if (state.liquidity <= 0n) { - if (state.liquidity < 0) { - this.logger.error( - `${this.dexKey}-${this.network}: ${pool.poolAddress} pool has negative liquidity: ${state.liquidity}. Find with key: ${pool.mapKey}`, - ); - } - this.logger.trace(`pool have 0 liquidity`); - return null; + // Filter eligible pools (positive liquidity) + const eligible: { + pool: PancakeSwapV3EventPool; + state: DeepReadonly; + idx: number; + }[] = []; + for (let i = 0; i < poolsToUse.poolWithState.length; i++) { + const pool = poolsToUse.poolWithState[i]; + const state = states[i]; + if (state.liquidity <= 0n) { + if (state.liquidity < 0) { + this.logger.error( + `${this.dexKey}-${this.network}: ${pool.poolAddress} pool has negative liquidity: ${state.liquidity}. Find with key: ${pool.mapKey}`, + ); } + this.logger.trace(`pool have 0 liquidity`); + continue; + } + eligible.push({ pool, state, idx: i }); + } + + // Batch query via registry (parallel Rust) or fallback to per-pool + const useBatchRust = + useRust !== false && this.registry && eligible.length > 1; + let batchUnitResults: Map | null = null; + let batchPriceResults: Map | null = null; + + if (useBatchRust) { + const poolKeys = eligible.map(e => e.pool.name); + const sideNum = side === SwapSide.SELL ? 0 : 1; + try { + const unitRaw = this.registry!.queryMany( + poolKeys, + [unitAmount], + zeroForOne, + sideNum, + ); + const priceRaw = this.registry!.queryMany( + poolKeys, + _amounts, + zeroForOne, + sideNum, + ); + batchUnitResults = new Map(unitRaw.map(r => [r.key, r])); + batchPriceResults = new Map(priceRaw.map(r => [r.key, r])); + } catch (e) { + this.logger.debug( + 'Batch Rust query failed, falling back to per-pool', + e, + ); + } + } - const balanceDestToken = - _destAddress === pool.token0 ? state.balance0 : state.balance1; + const result = eligible.map(({ pool, state }) => { + const balanceDestToken = + _destAddress === pool.token0 ? state.balance0 : state.balance1; + const poolKey = pool.name; + + let unitResult: OutputResult | null = null; + let pricesResult: OutputResult | null = null; + let usedRust = false; + + // Try batch results first + if (batchUnitResults && batchPriceResults) { + const ur = batchUnitResults.get(poolKey); + const pr = batchPriceResults.get(poolKey); + if (ur && ur.outputs.length > 0 && pr && pr.outputs.length > 0) { + unitResult = this._applyBalanceCap( + ur, + [unitAmount], + side, + balanceDestToken, + ); + pricesResult = this._applyBalanceCap( + pr, + _amounts, + side, + balanceDestToken, + ); + usedRust = true; + } + } - const unitResult = this._getOutputs( + // Fallback to per-pool JS math + if (!unitResult || !pricesResult) { + unitResult = this._getOutputs( state, [unitAmount], zeroForOne, side, balanceDestToken, ); - const pricesResult = this._getOutputs( + pricesResult = this._getOutputs( state, _amounts, zeroForOne, side, balanceDestToken, ); + } - if (!pricesResult) { - this.logger.debug('Prices or unit is not calculated'); - return null; - } + if (!unitResult || !pricesResult) { + this.logger.debug('Prices or unit is not calculated'); + return null; + } - const prices = [0n, ...pricesResult.outputs]; - const gasCost = [ - 0, - ...pricesResult.outputs.map((p, index) => { - if (p == 0n) { - return 0; - } else { - return ( - PANCAKESWAPV3_POOL_SEARCH_OVERHEAD + - PANCAKESWAPV3_TICK_BASE_OVERHEAD + - pricesResult.tickCounts[index] * PANCAKESWAPV3_TICK_GAS_COST - ); - } - }), - ]; - return { - unit: unitResult?.outputs[0] || 0n, - prices, - data: { - path: [ - { - tokenIn: _srcAddress, - tokenOut: _destAddress, - fee: pool.feeCode.toString(), - }, - ], - }, - poolIdentifiers: [ - this.getPoolIdentifier(pool.token0, pool.token1, pool.feeCode), + const prices = [0n, ...pricesResult.outputs]; + const gasCost = [ + 0, + ...pricesResult.outputs.map((p, index) => { + if (p == 0n) { + return 0; + } else { + return ( + PANCAKESWAPV3_POOL_SEARCH_OVERHEAD + + PANCAKESWAPV3_TICK_BASE_OVERHEAD + + pricesResult.tickCounts[index] * PANCAKESWAPV3_TICK_GAS_COST + ); + } + }), + ]; + return { + unit: unitResult?.outputs[0] || 0n, + prices, + data: { + path: [ + { + tokenIn: _srcAddress, + tokenOut: _destAddress, + fee: pool.feeCode.toString(), + }, ], - exchange: this.dexKey, - gasCost: gasCost, - poolAddresses: [pool.poolAddress], - }; - }), - ); + useRust: usedRust, + } as any, + poolIdentifiers: [ + this.getPoolIdentifier(pool.token0, pool.token1, pool.feeCode), + ], + exchange: this.dexKey, + gasCost: gasCost, + poolAddresses: [pool.poolAddress], + }; + }); const rpcResults = await rpcResultsPromise; const notNullResult = result.filter( @@ -1179,20 +1265,64 @@ export class PancakeswapV3 return newConfig; } + protected _applyBalanceCap( + result: OutputResult, + amounts: bigint[], + side: SwapSide, + destTokenBalance: bigint, + ): OutputResult | null { + const outputsResult = { + outputs: [...result.outputs], + tickCounts: [...result.tickCounts], + }; + + if (side === SwapSide.SELL) { + if (outputsResult.outputs[0] > destTokenBalance) { + return null; + } + for (let i = 0; i < outputsResult.outputs.length; i++) { + if (outputsResult.outputs[i] > destTokenBalance) { + outputsResult.outputs[i] = 0n; + outputsResult.tickCounts[i] = 0; + } + } + } else { + if (amounts[0] > destTokenBalance) { + return null; + } + for (let i = 0; i < amounts.length; i++) { + if (amounts[i] > destTokenBalance) { + outputsResult.outputs[i] = 0n; + outputsResult.tickCounts[i] = 0; + } + } + } + + return outputsResult; + } + private _getOutputs( state: DeepReadonly, amounts: bigint[], zeroForOne: boolean, side: SwapSide, destTokenBalance: bigint, + rustHandle?: { + queryOutputs( + amounts: bigint[], + zeroForOne: boolean, + side: number, + ): { outputs: bigint[]; tickCounts: number[] }; + } | null, ): OutputResult | null { try { - const outputsResult = pancakeswapV3Math.queryOutputs( - state, - amounts, - zeroForOne, - side, - ); + const outputsResult = rustHandle + ? rustHandle.queryOutputs( + amounts, + zeroForOne, + side === SwapSide.SELL ? 0 : 1, + ) + : pancakeswapV3Math.queryOutputs(state, amounts, zeroForOne, side); if (side === SwapSide.SELL) { if (outputsResult.outputs[0] > destTokenBalance) { diff --git a/src/dex/pancakeswap-v3/scripts/measure-calc-time.ts b/src/dex/pancakeswap-v3/scripts/measure-calc-time.ts new file mode 100644 index 0000000000..7cc75aea60 --- /dev/null +++ b/src/dex/pancakeswap-v3/scripts/measure-calc-time.ts @@ -0,0 +1,210 @@ +/* + * Benchmark: PancakeSwap V3 queryOutputs — JS vs Rust native addon. + * Verifies correctness and measures per-pool performance. + */ +import * as dotenv from 'dotenv'; +dotenv.config(); +import { getLogger } from '../../../lib/log4js'; +import { DeepReadonly } from 'ts-essentials'; +import { Network, SwapSide } from '../../../constants'; +import { DummyDexHelper } from '../../../dex-helper'; +import { pancakeswapV3Math } from '../contract-math/pancakeswap-v3-math'; +import { PoolState } from '../../uniswap-v3/types'; +import { PancakeswapV3 } from '../pancakeswap-v3'; +import { performance } from 'perf_hooks'; +import { + createRustHandle, + nativeAddonAvailable, + RustPoolHandleType, +} from '../../uniswap-v3/contract-math/native-bridge'; +import { getBigIntPow } from '../../../utils'; + +const logger = getLogger('PancakeSwapV3Benchmark'); +const runsNumber = 1000; +const network = Network.MAINNET; +const dexHelper = new DummyDexHelper(network); +const dex = new PancakeswapV3(network, 'PancakeswapV3', dexHelper); +const side = SwapSide.SELL; + +const pairs = [ + { + name: 'USDC/WETH', + src: { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + decimals: 6, + }, + dest: { + address: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + decimals: 18, + }, + }, + { + name: 'WETH/USDT', + src: { + address: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + decimals: 18, + }, + dest: { + address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + decimals: 6, + }, + }, + { + name: 'WBTC/WETH', + src: { + address: '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599', + decimals: 8, + }, + dest: { + address: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + decimals: 18, + }, + }, +]; + +function generateAmounts(decimals: number): bigint[] { + const unit = getBigIntPow(decimals); + const amounts: bigint[] = [0n]; + for (let i = 1; i <= 50; i++) amounts.push(unit * BigInt(i * 100)); + amounts.push(unit * 100000n, unit * 1000000n, unit * 10000000n); + return amounts; +} + +const sortTokens = (a: string, b: string) => + [a, b].sort((x, y) => (x < y ? -1 : 1)); + +function printStats(measures: number[], label: string) { + const sorted = [...measures].sort((a, b) => a - b); + const avg = sorted.reduce((a, b) => a + b) / sorted.length; + const p50 = sorted[Math.floor(sorted.length * 0.5)]; + const p95 = sorted[Math.floor(sorted.length * 0.95)]; + const p99 = sorted[Math.floor(sorted.length * 0.99)]; + const max = sorted[sorted.length - 1]; + logger.info( + ` [${label}] avg=${avg.toFixed(3)}ms | p50=${p50.toFixed(3)}ms | ` + + `p95=${p95.toFixed(3)}ms | p99=${p99.toFixed(3)}ms | max=${max.toFixed( + 3, + )}ms`, + ); +} + +async function benchmarkPair(pair: (typeof pairs)[0], blockNumber: number) { + const { name, src, dest } = pair; + const amounts = generateAmounts(src.decimals); + const [token0] = sortTokens( + src.address.toLowerCase(), + dest.address.toLowerCase(), + ); + const zeroForOne = token0 === src.address.toLowerCase(); + + logger.info(`\n${'='.repeat(60)}`); + logger.info(`PAIR: ${name} (${amounts.length} amounts, ${runsNumber} runs)`); + logger.info(`${'='.repeat(60)}\n`); + + await dex.getPricesVolume(src, dest, amounts, side, blockNumber); + + const poolEntries = Object.entries(dex.eventPools) + .filter(([key]) => { + const [s0, s1] = sortTokens( + src.address.toLowerCase(), + dest.address.toLowerCase(), + ); + return key.includes(s0) && key.includes(s1); + }) + .filter(([, ep]) => ep != null) + .map(([key, ep]) => ({ + key: key.split('_').pop()!, + pool: ep!, + state: ep!.getState(blockNumber)!, + })) + .filter(p => p.state !== null); + + if (!poolEntries.length) { + logger.warn(` No pools found for ${name}`); + return; + } + + logger.info(` Pools: ${poolEntries.map(p => `fee=${p.key}`).join(', ')}\n`); + for (const p of poolEntries) { + const ticks = Object.keys(p.state.ticks).length; + logger.info( + ` fee=${p.key}: ${ticks} ticks, liquidity=${p.state.liquidity}`, + ); + } + logger.info(''); + + logger.info(' --- JS ---'); + for (const pool of poolEntries) { + const measures: number[] = []; + for (let i = 0; i < runsNumber; i++) { + const start = performance.now(); + pancakeswapV3Math.queryOutputs(pool.state, amounts, zeroForOne, side); + measures.push(performance.now() - start); + } + printStats(measures, `JS fee=${pool.key}`); + } + + if (nativeAddonAvailable) { + logger.info(' --- Rust ---'); + for (const pool of poolEntries) { + const handle = createRustHandle(pool.state, 'pancakeswap_v3'); + if (!handle) { + logger.warn(` Failed to create Rust handle for fee=${pool.key}`); + continue; + } + + const measures: number[] = []; + for (let i = 0; i < runsNumber; i++) { + const start = performance.now(); + handle.queryOutputs(amounts, zeroForOne, 0); + measures.push(performance.now() - start); + } + printStats(measures, `Rust fee=${pool.key}`); + + // Correctness + const jsResult = pancakeswapV3Math.queryOutputs( + pool.state, + amounts, + zeroForOne, + side, + ); + const rustResult = handle.queryOutputs(amounts, zeroForOne, 0); + let mismatches = 0; + for (let k = 0; k < amounts.length; k++) { + if (jsResult.outputs[k] !== rustResult.outputs[k]) { + mismatches++; + if (mismatches <= 3) { + logger.error( + ` MISMATCH fee=${pool.key} amount[${k}]=${amounts[k]} ` + + `js=${jsResult.outputs[k]} rust=${rustResult.outputs[k]}`, + ); + } + } + } + logger.info( + mismatches === 0 + ? ` fee=${pool.key}: all ${amounts.length} outputs match ✓` + : ` fee=${pool.key}: ${mismatches}/${amounts.length} MISMATCHES`, + ); + } + } else { + logger.info(' Rust addon not available'); + } +} + +(async function main() { + logger.info(`PancakeSwap V3 benchmark (${runsNumber} runs)\n`); + const blockNumber = await dexHelper.web3Provider.eth.getBlockNumber(); + logger.info(`Block: ${blockNumber}`); + + for (const pair of pairs) { + try { + await benchmarkPair(pair, blockNumber); + } catch (e) { + logger.error(`Failed ${pair.name}:`, e); + } + } + + logger.info('\nDone.'); + process.exit(0); +})(); diff --git a/src/dex/solidly-v3/contract-math/native-bridge.ts b/src/dex/solidly-v3/contract-math/native-bridge.ts new file mode 100644 index 0000000000..a45bca936d --- /dev/null +++ b/src/dex/solidly-v3/contract-math/native-bridge.ts @@ -0,0 +1,120 @@ +import { DeepReadonly } from 'ts-essentials'; +import { PoolState } from '../types'; +import { NumberAsString } from '@paraswap/core'; +import { RustPoolRegistryType } from '../../uniswap-v3/contract-math/native-bridge'; + +export { RustPoolRegistryType }; + +// Try to load the native Rust addon +let nativeAddon: any = null; +try { + nativeAddon = require('../../../../native/index.js'); +} catch { + // Native addon not available — JS fallback will be used +} + +export const nativeAddonAvailable = nativeAddon !== null; + +// Allow forcing JS backend via env var +export const useNativeMath = + nativeAddonAvailable && process.env.PARASWAP_V3_MATH !== 'js'; + +export type RustPoolHandleType = { + queryOutputs( + amounts: bigint[], + zeroForOne: boolean, + side: number, + ): { outputs: bigint[]; tickCounts: number[] }; +}; + +/** + * Convert a Solidly V3 PoolState to the init format expected by the Rust addon. + * Solidly V3 differs from Uniswap V3: + * - fee is in slot0 (not a top-level field) + * - no feeProtocol in slot0 + * - no oracle observations + */ +function toRustInit(state: DeepReadonly) { + const tickBitmap = Object.entries( + state.tickBitmap as Record, + ).map(([key, value]) => ({ + key: Number(key), + value, + })); + + const ticks = Object.entries( + state.ticks as Record< + NumberAsString, + { liquidityGross: bigint; liquidityNet: bigint } + >, + ).map(([key, info]) => ({ + key: Number(key), + liquidityGross: info.liquidityGross, + liquidityNet: info.liquidityNet, + })); + + return { + variant: 'solidly_v3', + bitmapRange: 12, // TICK_BITMAP_BUFFER(8) + TICK_BITMAP_TO_USE(4) + blockTimestamp: state.blockTimestamp, + tickSpacing: state.tickSpacing, + fee: state.slot0.fee, + sqrtPriceX96: state.slot0.sqrtPriceX96, + tick: state.slot0.tick, + observationIndex: 0, + observationCardinality: 0, + observationCardinalityNext: 0, + feeProtocol: 0n, + liquidity: state.liquidity, + maxLiquidityPerTick: state.maxLiquidityPerTick, + startTickBitmap: state.startTickBitmap, + lowestKnownTick: state.lowestKnownTick, + highestKnownTick: state.highestKnownTick, + tickBitmap, + ticks, + observations: [], + }; +} + +/** + * Create a RustPoolHandle from a Solidly V3 PoolState. + * Returns null if the native addon is not available. + */ +export function createSolidlyRustHandle( + state: DeepReadonly, +): RustPoolHandleType | null { + if (!nativeAddonAvailable) return null; + try { + return nativeAddon.RustPoolHandle.create(toRustInit(state)); + } catch { + return null; + } +} + +/** + * Create a RustPoolRegistry for batch parallel queries. + * Returns null if the native addon is not available. + */ +export function createSolidlyRegistry(): RustPoolRegistryType | null { + if (!nativeAddonAvailable) return null; + try { + return new nativeAddon.RustPoolRegistry(); + } catch { + return null; + } +} + +/** + * Register a Solidly V3 pool in the registry using Solidly-specific state mapping. + */ +export function solidlyRegistrySetPool( + registry: RustPoolRegistryType, + key: string, + state: DeepReadonly, +): void { + try { + registry.setPool(key, toRustInit(state)); + } catch { + // silently skip — pool will use JS fallback + } +} diff --git a/src/dex/solidly-v3/scripts/measure-calc-time.ts b/src/dex/solidly-v3/scripts/measure-calc-time.ts new file mode 100644 index 0000000000..d697f40fee --- /dev/null +++ b/src/dex/solidly-v3/scripts/measure-calc-time.ts @@ -0,0 +1,209 @@ +/* + * Benchmark: Solidly V3 queryOutputs — JS vs Rust native addon. + * Verifies correctness and measures per-pool performance. + */ +import * as dotenv from 'dotenv'; +dotenv.config(); +import { getLogger } from '../../../lib/log4js'; +import { Network, SwapSide } from '../../../constants'; +import { DummyDexHelper } from '../../../dex-helper'; +import { uniswapV3Math } from '../contract-math/uniswap-v3-math'; +import { SolidlyV3 } from '../solidly-v3'; +import { performance } from 'perf_hooks'; +import { + createSolidlyRustHandle, + nativeAddonAvailable, +} from '../contract-math/native-bridge'; +import { getBigIntPow } from '../../../utils'; + +const logger = getLogger('SolidlyV3Benchmark'); +const runsNumber = 1000; +const network = Network.MAINNET; +const dexHelper = new DummyDexHelper(network); +const dex = new SolidlyV3(network, 'SolidlyV3', dexHelper); +const side = SwapSide.SELL; + +const pairs = [ + { + name: 'USDC/WETH', + src: { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + decimals: 6, + }, + dest: { + address: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + decimals: 18, + }, + }, + { + name: 'USDC/USDT', + src: { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + decimals: 6, + }, + dest: { + address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + decimals: 6, + }, + }, + { + name: 'WBTC/WETH', + src: { + address: '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599', + decimals: 8, + }, + dest: { + address: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + decimals: 18, + }, + }, +]; + +function generateAmounts(decimals: number): bigint[] { + const unit = getBigIntPow(decimals); + const amounts: bigint[] = [0n]; + for (let i = 1; i <= 50; i++) amounts.push(unit * BigInt(i * 100)); + amounts.push(unit * 100000n, unit * 1000000n, unit * 10000000n); + return amounts; +} + +const sortTokens = (a: string, b: string) => + [a, b].sort((x, y) => (x < y ? -1 : 1)); + +function printStats(measures: number[], label: string) { + const sorted = [...measures].sort((a, b) => a - b); + const avg = sorted.reduce((a, b) => a + b) / sorted.length; + const p50 = sorted[Math.floor(sorted.length * 0.5)]; + const p95 = sorted[Math.floor(sorted.length * 0.95)]; + const p99 = sorted[Math.floor(sorted.length * 0.99)]; + const max = sorted[sorted.length - 1]; + logger.info( + ` [${label}] avg=${avg.toFixed(3)}ms | p50=${p50.toFixed(3)}ms | ` + + `p95=${p95.toFixed(3)}ms | p99=${p99.toFixed(3)}ms | max=${max.toFixed( + 3, + )}ms`, + ); +} + +async function benchmarkPair(pair: (typeof pairs)[0], blockNumber: number) { + const { name, src, dest } = pair; + const amounts = generateAmounts(src.decimals); + const [token0] = sortTokens( + src.address.toLowerCase(), + dest.address.toLowerCase(), + ); + const zeroForOne = token0 === src.address.toLowerCase(); + + logger.info(`\n${'='.repeat(60)}`); + logger.info(`PAIR: ${name} (${amounts.length} amounts, ${runsNumber} runs)`); + logger.info(`${'='.repeat(60)}\n`); + + await dex.getPricesVolume(src, dest, amounts, side, blockNumber); + + const poolEntries = Object.entries(dex.eventPools) + .filter(([key]) => { + const [s0, s1] = sortTokens( + src.address.toLowerCase(), + dest.address.toLowerCase(), + ); + return key.includes(s0) && key.includes(s1); + }) + .filter(([, ep]) => ep != null) + .map(([key, ep]) => ({ + key: key.split('_').pop()!, + pool: ep!, + state: ep!.getState(blockNumber)!, + })) + .filter(p => p.state !== null); + + if (!poolEntries.length) { + logger.warn(` No pools found for ${name}`); + return; + } + + logger.info( + ` Pools: ${poolEntries.map(p => `tickSpacing=${p.key}`).join(', ')}\n`, + ); + for (const p of poolEntries) { + const ticks = Object.keys(p.state.ticks).length; + logger.info( + ` tickSpacing=${p.key}: ${ticks} ticks, fee=${p.state.slot0.fee}, liquidity=${p.state.liquidity}`, + ); + } + logger.info(''); + + logger.info(' --- JS ---'); + for (const pool of poolEntries) { + const measures: number[] = []; + for (let i = 0; i < runsNumber; i++) { + const start = performance.now(); + uniswapV3Math.queryOutputs(pool.state, amounts, zeroForOne, side); + measures.push(performance.now() - start); + } + printStats(measures, `JS ts=${pool.key}`); + } + + if (nativeAddonAvailable) { + logger.info(' --- Rust ---'); + for (const pool of poolEntries) { + const handle = createSolidlyRustHandle(pool.state); + if (!handle) { + logger.warn(` Failed to create Rust handle for ts=${pool.key}`); + continue; + } + + const measures: number[] = []; + for (let i = 0; i < runsNumber; i++) { + const start = performance.now(); + handle.queryOutputs(amounts, zeroForOne, 0); + measures.push(performance.now() - start); + } + printStats(measures, `Rust ts=${pool.key}`); + + // Correctness + const jsResult = uniswapV3Math.queryOutputs( + pool.state, + amounts, + zeroForOne, + side, + ); + const rustResult = handle.queryOutputs(amounts, zeroForOne, 0); + let mismatches = 0; + for (let k = 0; k < amounts.length; k++) { + if (jsResult.outputs[k] !== rustResult.outputs[k]) { + mismatches++; + if (mismatches <= 3) { + logger.error( + ` MISMATCH ts=${pool.key} amount[${k}]=${amounts[k]} ` + + `js=${jsResult.outputs[k]} rust=${rustResult.outputs[k]}`, + ); + } + } + } + logger.info( + mismatches === 0 + ? ` ts=${pool.key}: all ${amounts.length} outputs match ✓` + : ` ts=${pool.key}: ${mismatches}/${amounts.length} MISMATCHES`, + ); + } + } else { + logger.info(' Rust addon not available'); + } +} + +(async function main() { + logger.info(`Solidly V3 benchmark (${runsNumber} runs)\n`); + const blockNumber = await dexHelper.web3Provider.eth.getBlockNumber(); + logger.info(`Block: ${blockNumber}`); + + for (const pair of pairs) { + try { + await benchmarkPair(pair, blockNumber); + } catch (e) { + logger.error(`Failed ${pair.name}:`, e); + } + } + + logger.info('\nDone.'); + process.exit(0); +})(); diff --git a/src/dex/solidly-v3/solidly-v3-pool.ts b/src/dex/solidly-v3/solidly-v3-pool.ts index 3a30b7278e..7ea8a05d83 100644 --- a/src/dex/solidly-v3/solidly-v3-pool.ts +++ b/src/dex/solidly-v3/solidly-v3-pool.ts @@ -27,6 +27,10 @@ import { TickBitMap } from './contract-math/TickBitMap'; import { uint256ToBigInt } from '../../lib/decoders'; import { decodeStateMultiCallResultWithRelativeBitmaps } from './utils'; import { _reduceTickBitmap, _reduceTicks } from './contract-math/utils'; +import { + solidlyRegistrySetPool, + RustPoolRegistryType, +} from './contract-math/native-bridge'; const FEES_TO_TICK_SPACING: Record = { 500: 10n, @@ -59,6 +63,8 @@ export class SolidlyV3EventPool extends StatefulEventSubscriber { public initFailed = false; public initRetryAttemptCount = 0; + public registry: RustPoolRegistryType | null = null; + // public readonly feeCodeAsString; public readonly tickSpacingAsString: string; @@ -258,6 +264,13 @@ export class SolidlyV3EventPool extends StatefulEventSubscriber { return TICK_BITMAP_TO_USE + TICK_BITMAP_BUFFER; } + _setState(state: any, blockNumber: number, reason?: string): void { + super._setState(state, blockNumber); + if (this.registry && state) { + solidlyRegistrySetPool(this.registry, this.name, state); + } + } + async generateState(blockNumber: number): Promise> { const callData = this._getStateRequestCallData(); diff --git a/src/dex/solidly-v3/solidly-v3.ts b/src/dex/solidly-v3/solidly-v3.ts index 40e4a27ce5..7b800c6c69 100644 --- a/src/dex/solidly-v3/solidly-v3.ts +++ b/src/dex/solidly-v3/solidly-v3.ts @@ -50,6 +50,11 @@ import { AbiItem } from 'web3-utils'; import { TickMath } from './contract-math/TickMath'; import { OnPoolCreatedCallback, SolidlyV3Factory } from './solidly-v3-factory'; import { SpecialDex } from '../../executor/types'; +import { + createSolidlyRegistry, + RustPoolHandleType, + RustPoolRegistryType, +} from './contract-math/native-bridge'; type PoolPairsInfo = { token0: Address; @@ -78,6 +83,9 @@ export class SolidlyV3 readonly hasConstantPriceLargeAmounts = false; readonly needWrapNative = true; + public readonly registry: RustPoolRegistryType | null = + createSolidlyRegistry(); + readonly directSwapIface = new Interface(DirectSwapABI); intervalTask?: NodeJS.Timeout; @@ -127,6 +135,12 @@ export class SolidlyV3 this.notExistingPoolSetKey = `${CACHE_PREFIX}_${network}_${dexKey}_not_existings_pool_set`.toLowerCase(); + + this.logger.info( + `${dexKey}: native Rust math ${ + this.registry ? 'enabled' : 'not available' + }`, + ); } get supportedTickSpacings() { @@ -323,6 +337,7 @@ export class SolidlyV3 this.cacheStateKey, this.config.initHash, ); + pool.registry = this.registry; let resultPool: SolidlyV3EventPool | null = pool; @@ -447,6 +462,9 @@ export class SolidlyV3 side: SwapSide, blockNumber: number, limitPools?: string[], + _transferFees?: any, + _isFirstSwap?: boolean, + useRust?: boolean, ): Promise> { try { const _srcToken = this.dexHelper.config.wrapETH(srcToken); @@ -545,78 +563,143 @@ export class SolidlyV3 const zeroForOne = token0 === _srcAddress ? true : false; - const result = await Promise.all( - poolsToUse.poolWithState.map(async (pool, i) => { - const state = states[i]; - - if (state.liquidity <= 0n) { - if (state.liquidity < 0) { - this.logger.error( - `${this.dexKey}-${this.network}: ${pool.poolAddress} pool has negative liquidity: ${state.liquidity}. Find with key: ${pool.mapKey}`, - ); - } - this.logger.trace(`pool have 0 liquidity`); - return null; + // Filter eligible pools (positive liquidity) + const eligible: { + pool: SolidlyV3EventPool; + state: DeepReadonly; + idx: number; + }[] = []; + for (let i = 0; i < poolsToUse.poolWithState.length; i++) { + const pool = poolsToUse.poolWithState[i]; + const state = states[i]; + if (state.liquidity <= 0n) { + if (state.liquidity < 0) { + this.logger.error( + `${this.dexKey}-${this.network}: ${pool.poolAddress} pool has negative liquidity: ${state.liquidity}. Find with key: ${pool.mapKey}`, + ); } + this.logger.trace(`pool have 0 liquidity`); + continue; + } + eligible.push({ pool, state, idx: i }); + } - const balanceDestToken = - _destAddress === pool.token0 ? state.balance0 : state.balance1; + // Batch query via registry (parallel Rust) or fallback to per-pool + const useBatchRust = + useRust !== false && this.registry && eligible.length > 1; + let batchUnitResults: Map | null = null; + let batchPriceResults: Map | null = null; + + if (useBatchRust) { + const poolKeys = eligible.map(e => e.pool.name); + const sideNum = side === SwapSide.SELL ? 0 : 1; + try { + const unitRaw = this.registry!.queryMany( + poolKeys, + [unitAmount], + zeroForOne, + sideNum, + ); + const priceRaw = this.registry!.queryMany( + poolKeys, + _amounts, + zeroForOne, + sideNum, + ); + batchUnitResults = new Map(unitRaw.map(r => [r.key, r])); + batchPriceResults = new Map(priceRaw.map(r => [r.key, r])); + } catch (e) { + this.logger.debug( + 'Batch Rust query failed, falling back to per-pool', + e, + ); + } + } - const unitResult = this._getOutputs( + const result = eligible.map(({ pool, state }) => { + const balanceDestToken = + _destAddress === pool.token0 ? state.balance0 : state.balance1; + const poolKey = pool.name; + + let unitResult: OutputResult | null = null; + let pricesResult: OutputResult | null = null; + let usedRust = false; + + // Try batch results first + if (batchUnitResults && batchPriceResults) { + const ur = batchUnitResults.get(poolKey); + const pr = batchPriceResults.get(poolKey); + if (ur && ur.outputs.length > 0 && pr && pr.outputs.length > 0) { + unitResult = this._applyBalanceCap( + ur, + [unitAmount], + side, + balanceDestToken, + ); + pricesResult = this._applyBalanceCap( + pr, + _amounts, + side, + balanceDestToken, + ); + usedRust = true; + } + } + + // Fallback to per-pool JS math + if (!unitResult || !pricesResult) { + unitResult = this._getOutputs( state, [unitAmount], zeroForOne, side, balanceDestToken, ); - const pricesResult = this._getOutputs( + pricesResult = this._getOutputs( state, _amounts, zeroForOne, side, balanceDestToken, ); + } - if (!unitResult || !pricesResult) { - this.logger.debug('Prices or unit is not calculated'); - return null; - } + if (!unitResult || !pricesResult) { + this.logger.debug('Prices or unit is not calculated'); + return null; + } - const prices = [0n, ...pricesResult.outputs]; - const gasCost = [ - 0, - ...pricesResult.outputs.map((p, index) => { - if (p == 0n) { - return 0; - } else { - return ( - UNISWAPV3_POOL_SEARCH_OVERHEAD + - UNISWAPV3_TICK_BASE_OVERHEAD + - pricesResult.tickCounts[index] * UNISWAPV3_TICK_GAS_COST - ); - } - }), - ]; - return { - unit: unitResult.outputs[0], - prices, - data: { - zeroForOne, - poolAddress: pool.poolAddress, - }, - poolIdentifiers: [ - this.getPoolIdentifier( - pool.token0, - pool.token1, - pool.tickSpacing, - ), - ], - exchange: this.dexKey, - gasCost: gasCost, - poolAddresses: [pool.poolAddress], - }; - }), - ); + const prices = [0n, ...pricesResult.outputs]; + const gasCost = [ + 0, + ...pricesResult.outputs.map((p, index) => { + if (p == 0n) { + return 0; + } else { + return ( + UNISWAPV3_POOL_SEARCH_OVERHEAD + + UNISWAPV3_TICK_BASE_OVERHEAD + + pricesResult.tickCounts[index] * UNISWAPV3_TICK_GAS_COST + ); + } + }), + ]; + return { + unit: unitResult.outputs[0], + prices, + data: { + zeroForOne, + poolAddress: pool.poolAddress, + useRust: usedRust, + } as any, + poolIdentifiers: [ + this.getPoolIdentifier(pool.token0, pool.token1, pool.tickSpacing), + ], + exchange: this.dexKey, + gasCost: gasCost, + poolAddresses: [pool.poolAddress], + }; + }); const notNullResult = result.filter( res => res !== null, @@ -898,20 +981,58 @@ export class SolidlyV3 return newConfig; } + protected _applyBalanceCap( + result: OutputResult, + amounts: bigint[], + side: SwapSide, + destTokenBalance: bigint, + ): OutputResult | null { + const outputsResult = { + outputs: [...result.outputs], + tickCounts: [...result.tickCounts], + }; + + if (side === SwapSide.SELL) { + if (outputsResult.outputs[0] > destTokenBalance) { + return null; + } + for (let i = 0; i < outputsResult.outputs.length; i++) { + if (outputsResult.outputs[i] > destTokenBalance) { + outputsResult.outputs[i] = 0n; + outputsResult.tickCounts[i] = 0; + } + } + } else { + if (amounts[0] > destTokenBalance) { + return null; + } + for (let i = 0; i < amounts.length; i++) { + if (amounts[i] > destTokenBalance) { + outputsResult.outputs[i] = 0n; + outputsResult.tickCounts[i] = 0; + } + } + } + + return outputsResult; + } + private _getOutputs( state: DeepReadonly, amounts: bigint[], zeroForOne: boolean, side: SwapSide, destTokenBalance: bigint, + rustHandle?: RustPoolHandleType | null, ): OutputResult | null { try { - const outputsResult = uniswapV3Math.queryOutputs( - state, - amounts, - zeroForOne, - side, - ); + const outputsResult = rustHandle + ? rustHandle.queryOutputs( + amounts, + zeroForOne, + side === SwapSide.SELL ? 0 : 1, + ) + : uniswapV3Math.queryOutputs(state, amounts, zeroForOne, side); if (side === SwapSide.SELL) { if (outputsResult.outputs[0] > destTokenBalance) { diff --git a/src/dex/uniswap-v3/contract-math/native-bridge.ts b/src/dex/uniswap-v3/contract-math/native-bridge.ts new file mode 100644 index 0000000000..ead6d4e7a3 --- /dev/null +++ b/src/dex/uniswap-v3/contract-math/native-bridge.ts @@ -0,0 +1,167 @@ +import { DeepReadonly } from 'ts-essentials'; +import { PoolState } from '../types'; +import { NumberAsString } from '@paraswap/core'; +import { + TICK_BITMAP_TO_USE, + TICK_BITMAP_BUFFER, + TICK_BITMAP_TO_USE_BY_CHAIN, + TICK_BITMAP_BUFFER_BY_CHAIN, +} from '../constants'; + +// Try to load the native Rust addon +let nativeAddon: any = null; +try { + nativeAddon = require('../../../../native/index.js'); +} catch { + // Native addon not available — JS fallback will be used +} + +export const nativeAddonAvailable = nativeAddon !== null; + +// Allow forcing JS backend via env var +export const useNativeMath = + nativeAddonAvailable && process.env.PARASWAP_V3_MATH !== 'js'; + +export type RustPoolHandleType = { + queryOutputs( + amounts: bigint[], + zeroForOne: boolean, + side: number, + ): { outputs: bigint[]; tickCounts: number[] }; +}; + +/** + * Convert a PoolState to the init format expected by the Rust addon. + */ +function toRustInit( + state: DeepReadonly, + variant: string = 'uniswap_v3', + bitmapRangeOverride?: number, +) { + const tickBitmap = Object.entries( + state.tickBitmap as Record, + ).map(([key, value]) => ({ + key: Number(key), + value, + })); + + const ticks = Object.entries( + state.ticks as Record< + NumberAsString, + { liquidityGross: bigint; liquidityNet: bigint } + >, + ).map(([key, info]) => ({ + key: Number(key), + liquidityGross: info.liquidityGross, + liquidityNet: info.liquidityNet, + })); + + const observations = Object.entries( + state.observations as Record< + number, + { + blockTimestamp: bigint; + tickCumulative: bigint; + secondsPerLiquidityCumulativeX128: bigint; + initialized: boolean; + } + >, + ).map(([key, obs]) => ({ + key: Number(key), + blockTimestamp: obs.blockTimestamp, + tickCumulative: obs.tickCumulative, + secondsPerLiquidityCumulativeX128: obs.secondsPerLiquidityCumulativeX128, + initialized: obs.initialized, + })); + + const bitmapUse = Number( + TICK_BITMAP_TO_USE_BY_CHAIN[state.networkId] ?? TICK_BITMAP_TO_USE, + ); + const bitmapBuffer = Number( + TICK_BITMAP_BUFFER_BY_CHAIN[state.networkId] ?? TICK_BITMAP_BUFFER, + ); + + return { + variant, + bitmapRange: bitmapRangeOverride ?? bitmapBuffer + bitmapUse, + blockTimestamp: state.blockTimestamp, + tickSpacing: state.tickSpacing, + fee: state.fee, + sqrtPriceX96: state.slot0.sqrtPriceX96, + tick: state.slot0.tick, + observationIndex: state.slot0.observationIndex, + observationCardinality: state.slot0.observationCardinality, + observationCardinalityNext: state.slot0.observationCardinalityNext, + feeProtocol: state.slot0.feeProtocol, + liquidity: state.liquidity, + maxLiquidityPerTick: state.maxLiquidityPerTick, + startTickBitmap: state.startTickBitmap, + lowestKnownTick: state.lowestKnownTick, + highestKnownTick: state.highestKnownTick, + tickBitmap, + ticks, + observations, + }; +} + +/** + * Create a RustPoolHandle from a PoolState. + * Returns null if the native addon is not available. + */ +export function createRustHandle( + state: DeepReadonly, + variant: string = 'uniswap_v3', + bitmapRange?: number, +): RustPoolHandleType | null { + if (!nativeAddonAvailable) return null; + try { + return nativeAddon.RustPoolHandle.create( + toRustInit(state, variant, bitmapRange), + ); + } catch { + return null; + } +} + +// ---- Pool Registry for batch parallel queries ---- + +export type RegistryQueryResult = { + key: string; + outputs: bigint[]; + tickCounts: number[]; +}; + +export type RustPoolRegistryType = { + setPool(key: string, init: ReturnType): void; + removePool(key: string): void; + queryMany( + keys: string[], + amounts: bigint[], + zeroForOne: boolean, + side: number, + ): RegistryQueryResult[]; + poolCount(): number; +}; + +export function createRegistry(): RustPoolRegistryType | null { + if (!nativeAddonAvailable) return null; + try { + return new nativeAddon.RustPoolRegistry(); + } catch { + return null; + } +} + +export function registrySetPool( + registry: RustPoolRegistryType, + key: string, + state: DeepReadonly, + variant: string = 'uniswap_v3', + bitmapRange?: number, +): void { + try { + registry.setPool(key, toRustInit(state, variant, bitmapRange)); + } catch { + // silently skip — pool will use JS fallback + } +} diff --git a/src/dex/uniswap-v3/scripts/bench-e2e.ts b/src/dex/uniswap-v3/scripts/bench-e2e.ts new file mode 100644 index 0000000000..6fcdbf0d4e --- /dev/null +++ b/src/dex/uniswap-v3/scripts/bench-e2e.ts @@ -0,0 +1,67 @@ +/* eslint-disable no-console */ +import * as dotenv from 'dotenv'; +dotenv.config(); +import { Network, SwapSide } from '../../../constants'; +import { DummyDexHelper } from '../../../dex-helper'; +import { UniswapV3 } from '../uniswap-v3'; +import { performance } from 'perf_hooks'; +import { getBigIntPow } from '../../../utils'; + +(async () => { + const dh = new DummyDexHelper(Network.MAINNET); + const bn = await dh.web3Provider.eth.getBlockNumber(); + const dex = new UniswapV3(Network.MAINNET, 'UniswapV3', dh); + + const USDC = { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + decimals: 6, + }; + const WETH = { + address: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + decimals: 18, + }; + const amounts = [ + 0n, + ...Array.from( + { length: 53 }, + (_, i) => getBigIntPow(6) * BigInt((i + 1) * 100), + ), + ]; + + // First call initializes pools + const result = await dex.getPricesVolume( + USDC, + WETH, + amounts, + SwapSide.SELL, + bn, + ); + if (!result) { + console.log('No results'); + process.exit(1); + } + + console.log(`getPricesVolume: ${result.length} pool results`); + for (const r of result) { + console.log( + ` ${(r.poolAddresses || [])[0]?.slice(0, 10)}... useRust=${ + (r.data as any).useRust + } unit=${r.unit}`, + ); + } + console.log(`Registry pools: ${dex.registry?.poolCount() ?? 'N/A'}`); + + // Benchmark + const measures: number[] = []; + for (let i = 0; i < 1000; i++) { + const s = performance.now(); + await dex.getPricesVolume(USDC, WETH, amounts, SwapSide.SELL, bn); + measures.push(performance.now() - s); + } + const sorted = [...measures].sort((a, b) => a - b); + console.log(`\ngetPricesVolume end-to-end (1000 runs):`); + console.log(` p50=${sorted[Math.floor(sorted.length * 0.5)].toFixed(3)}ms`); + console.log(` p99=${sorted[Math.floor(sorted.length * 0.99)].toFixed(3)}ms`); + console.log(` max=${sorted[sorted.length - 1].toFixed(3)}ms`); + process.exit(0); +})(); diff --git a/src/dex/uniswap-v3/scripts/bench-parallel.ts b/src/dex/uniswap-v3/scripts/bench-parallel.ts new file mode 100644 index 0000000000..de4fc8d734 --- /dev/null +++ b/src/dex/uniswap-v3/scripts/bench-parallel.ts @@ -0,0 +1,203 @@ +/* eslint-disable no-console */ +import * as dotenv from 'dotenv'; +dotenv.config(); +import { Network, SwapSide } from '../../../constants'; +import { DummyDexHelper } from '../../../dex-helper'; +import { uniswapV3Math } from '../contract-math/uniswap-v3-math'; +import { UniswapV3 } from '../uniswap-v3'; +import { createRustHandle } from '../contract-math/native-bridge'; +import { performance } from 'perf_hooks'; +import { getBigIntPow } from '../../../utils'; +import { + TICK_BITMAP_TO_USE_BY_CHAIN, + TICK_BITMAP_BUFFER_BY_CHAIN, + TICK_BITMAP_TO_USE, + TICK_BITMAP_BUFFER, +} from '../constants'; + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const native = require('../../../../native/index.js'); + +const RUNS = 2000; + +function stats(m: number[]) { + const s = [...m].sort((a, b) => a - b); + return { + avg: (s.reduce((a, b) => a + b) / s.length).toFixed(3), + p50: s[Math.floor(s.length * 0.5)].toFixed(3), + p99: s[Math.floor(s.length * 0.99)].toFixed(3), + max: s[s.length - 1].toFixed(3), + }; +} + +(async () => { + const dh = new DummyDexHelper(Network.MAINNET); + const bn = await dh.web3Provider.eth.getBlockNumber(); + const dex = new UniswapV3(Network.MAINNET, 'UniswapV3', dh); + + const USDC = { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + decimals: 6, + }; + const WETH = { + address: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + decimals: 18, + }; + const WBTC = { + address: '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599', + decimals: 8, + }; + const amounts = [ + 0n, + ...Array.from( + { length: 53 }, + (_, i) => getBigIntPow(6) * BigInt((i + 1) * 100), + ), + ]; + + await dex.getPricesVolume(USDC, WETH, amounts, SwapSide.SELL, bn); + await dex.getPricesVolume( + WBTC, + WETH, + [ + 0n, + ...Array.from({ length: 53 }, (_, i) => getBigIntPow(8) * BigInt(i + 1)), + ], + SwapSide.SELL, + bn, + ); + + const zfo = + [USDC.address.toLowerCase(), WETH.address.toLowerCase()].sort()[0] === + USDC.address.toLowerCase(); + + const pools: { key: string; state: any }[] = []; + for (const [k, ep] of Object.entries(dex.eventPools)) { + if (!ep) continue; + const st = ep.getState(bn); + if (!st || st.liquidity <= 0n) continue; + pools.push({ key: k, state: st }); + } + + console.log( + `\nPools: ${pools.length} | Amounts: ${amounts.length} | Runs: ${RUNS}\n`, + ); + + // JS sequential + const jsM: number[] = []; + for (let i = 0; i < RUNS; i++) { + const s = performance.now(); + for (const p of pools) + uniswapV3Math.queryOutputs(p.state, amounts, zfo, SwapSide.SELL); + jsM.push(performance.now() - s); + } + const js = stats(jsM); + console.log( + `JS sequential: avg=${js.avg}ms p50=${js.p50}ms p99=${js.p99}ms max=${js.max}ms`, + ); + + // Rust sequential + const handles = pools.map(p => ({ + key: p.key, + h: createRustHandle(p.state)!, + })); + const rsM: number[] = []; + for (let i = 0; i < RUNS; i++) { + const s = performance.now(); + for (const h of handles) h.h.queryOutputs(amounts, zfo, 0); + rsM.push(performance.now() - s); + } + const rs = stats(rsM); + console.log( + `Rust sequential: avg=${rs.avg}ms p50=${rs.p50}ms p99=${rs.p99}ms max=${rs.max}ms`, + ); + + // Rust parallel (registry) + const reg = new native.RustPoolRegistry(); + const keys: string[] = []; + for (const p of pools) { + const s = p.state; + const bu = Number( + TICK_BITMAP_TO_USE_BY_CHAIN[s.networkId] ?? TICK_BITMAP_TO_USE, + ); + const bb = Number( + TICK_BITMAP_BUFFER_BY_CHAIN[s.networkId] ?? TICK_BITMAP_BUFFER, + ); + reg.setPool(p.key, { + variant: 'uniswap_v3', + bitmapRange: bu + bb, + blockTimestamp: s.blockTimestamp, + tickSpacing: s.tickSpacing, + fee: s.fee, + sqrtPriceX96: s.slot0.sqrtPriceX96, + tick: s.slot0.tick, + observationIndex: s.slot0.observationIndex, + observationCardinality: s.slot0.observationCardinality, + observationCardinalityNext: s.slot0.observationCardinalityNext, + feeProtocol: s.slot0.feeProtocol, + liquidity: s.liquidity, + maxLiquidityPerTick: s.maxLiquidityPerTick, + startTickBitmap: s.startTickBitmap, + lowestKnownTick: s.lowestKnownTick, + highestKnownTick: s.highestKnownTick, + tickBitmap: Object.entries(s.tickBitmap).map(([k, v]) => ({ + key: Number(k), + value: v, + })), + ticks: Object.entries(s.ticks).map(([k, v]: any) => ({ + key: Number(k), + liquidityGross: v.liquidityGross, + liquidityNet: v.liquidityNet, + })), + observations: Object.entries(s.observations).map(([k, v]: any) => ({ + key: Number(k), + blockTimestamp: v.blockTimestamp, + tickCumulative: v.tickCumulative, + secondsPerLiquidityCumulativeX128: v.secondsPerLiquidityCumulativeX128, + initialized: v.initialized, + })), + }); + keys.push(p.key); + } + + const parM: number[] = []; + for (let i = 0; i < RUNS; i++) { + const s = performance.now(); + reg.queryMany(keys, amounts, zfo, 0); + parM.push(performance.now() - s); + } + const par = stats(parM); + const threads = process.env.RAYON_NUM_THREADS || 'all'; + console.log( + `Rust PARALLEL: avg=${par.avg}ms p50=${par.p50}ms p99=${par.p99}ms max=${par.max}ms (threads=${threads})`, + ); + + // Correctness + const pr = reg.queryMany(keys, amounts, zfo, 0); + let ok = true; + for (const r of pr) { + const p = pools.find(p => p.key === r.key)!; + const jr = uniswapV3Math.queryOutputs(p.state, amounts, zfo, SwapSide.SELL); + for (let i = 0; i < amounts.length; i++) { + if (jr.outputs[i] !== r.outputs[i]) { + ok = false; + break; + } + } + } + console.log(`\nCorrectness: ${ok ? 'ALL MATCH' : 'MISMATCH'}`); + + console.log(`\n=== ${pools.length} pools, ${amounts.length} amounts ===`); + console.log(`JS sequential: ${js.p50}ms`); + console.log( + `Rust sequential: ${rs.p50}ms (${( + parseFloat(js.p50) / parseFloat(rs.p50) + ).toFixed(1)}x)`, + ); + console.log( + `Rust parallel: ${par.p50}ms (${( + parseFloat(js.p50) / parseFloat(par.p50) + ).toFixed(1)}x) [${threads} threads]`, + ); + process.exit(0); +})(); diff --git a/src/dex/uniswap-v3/scripts/measure-calc-time.ts b/src/dex/uniswap-v3/scripts/measure-calc-time.ts index aed2b6fcfc..f3c164212e 100644 --- a/src/dex/uniswap-v3/scripts/measure-calc-time.ts +++ b/src/dex/uniswap-v3/scripts/measure-calc-time.ts @@ -1,7 +1,9 @@ /* -The purpose of this script is to measure the real calculation time for price -request worst case scenario. +Measures real calculation time for queryOutputs across multiple token pairs. +Compares JS BigInt vs Rust native addon. + +Uses performance.now() for sub-millisecond precision. */ import * as dotenv from 'dotenv'; @@ -13,160 +15,292 @@ import { DummyDexHelper } from '../../../dex-helper'; import { uniswapV3Math } from '../contract-math/uniswap-v3-math'; import { PoolState } from '../types'; import { UniswapV3 } from '../uniswap-v3'; +import { performance } from 'perf_hooks'; +import { + createRustHandle, + nativeAddonAvailable, + RustPoolHandleType, +} from '../contract-math/native-bridge'; +import { getBigIntPow } from '../../../utils'; const logger = getLogger('UniswapV3MeasureScript'); const runsNumber = 1000; -const printFrequency = 100; const network = Network.MAINNET; const dexHelper = new DummyDexHelper(network); - const uniV3 = new UniswapV3(network, 'UniswapV3', dexHelper); +const side = SwapSide.SELL; -// USDC -const srcToken = { - address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', - decimals: 6, -}; +// --- Token addresses --- +const WETH = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'; +const USDC = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; +const USDT = '0xdAC17F958D2ee523a2206206994597C13D831ec7'; +const WBTC = '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599'; +const DAI = '0x6B175474E89094C44Da98b954EedeAC495271d0F'; +const PEPE = '0x6982508145454Ce325dDbE47a25d4ec3d2311933'; +const LINK = '0x514910771AF9Ca656af840dff83E8264EcF986CA'; +const UNI = '0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984'; +const MKR = '0x9f8F72aA9304c8B593d555F12eF6589cC3A579A2'; +const SHIB = '0x95aD61b0a150d79219dCF64E1E6Cc01f0B64C4cE'; -// WETH -const destToken = { - address: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', - decimals: 18, -}; - -// 2_000_000 -> 100_000_000 (50 chunks) -const amounts = [ - 0n, - 2000000000000n, - 4000000000000n, - 6000000000000n, - 8000000000000n, - 10000000000000n, - 12000000000000n, - 14000000000000n, - 16000000000000n, - 18000000000000n, - 20000000000000n, - 22000000000000n, - 24000000000000n, - 26000000000000n, - 28000000000000n, - 30000000000000n, - 32000000000000n, - 34000000000000n, - 36000000000000n, - 38000000000000n, - 40000000000000n, - 42000000000000n, - 44000000000000n, - 46000000000000n, - 48000000000000n, - 50000000000000n, - 52000000000000n, - 54000000000000n, - 56000000000000n, - 58000000000000n, - 60000000000000n, - 62000000000000n, - 64000000000000n, - 66000000000000n, - 68000000000000n, - 70000000000000n, - 72000000000000n, - 74000000000000n, - 76000000000000n, - 78000000000000n, - 80000000000000n, - 82000000000000n, - 84000000000000n, - 86000000000000n, - 88000000000000n, - 90000000000000n, - 92000000000000n, - 94000000000000n, - 96000000000000n, - 98000000000000n, - 100000000000000n, - 1000000000000000n, - 10000000000000000n, - 100000000000000000n, +// --- Token pairs to benchmark --- +const pairs = [ + // Stablecoin pairs (tight liquidity, many ticks) + { + name: 'USDC/WETH (stable, concentrated)', + src: { address: USDC, decimals: 6 }, + dest: { address: WETH, decimals: 18 }, + }, + { + name: 'USDT/WETH (stable)', + src: { address: USDT, decimals: 6 }, + dest: { address: WETH, decimals: 18 }, + }, + { + name: 'DAI/WETH (stable 18-dec)', + src: { address: DAI, decimals: 18 }, + dest: { address: WETH, decimals: 18 }, + }, + { + name: 'USDC/USDT (stablecoin pair)', + src: { address: USDC, decimals: 6 }, + dest: { address: USDT, decimals: 6 }, + }, + // Major volatile pairs + { + name: 'WBTC/WETH (volatile, wider spread)', + src: { address: WBTC, decimals: 8 }, + dest: { address: WETH, decimals: 18 }, + }, + { + name: 'LINK/WETH (mid-cap)', + src: { address: LINK, decimals: 18 }, + dest: { address: WETH, decimals: 18 }, + }, + { + name: 'UNI/WETH (mid-cap)', + src: { address: UNI, decimals: 18 }, + dest: { address: WETH, decimals: 18 }, + }, + { + name: 'MKR/WETH (low liquidity, few ticks)', + src: { address: MKR, decimals: 18 }, + dest: { address: WETH, decimals: 18 }, + }, + // Meme / high-volatility (many tick crossings) + { + name: 'PEPE/WETH (meme, very volatile)', + src: { address: PEPE, decimals: 18 }, + dest: { address: WETH, decimals: 18 }, + }, + { + name: 'SHIB/WETH (meme, high tick density)', + src: { address: SHIB, decimals: 18 }, + dest: { address: WETH, decimals: 18 }, + }, ]; -const side = SwapSide.SELL; -const sortTokens = (srcAddress: string, destAddress: string) => { - return [srcAddress, destAddress].sort((a, b) => (a < b ? -1 : 1)); -}; +function generateAmounts(decimals: number): bigint[] { + const unit = getBigIntPow(decimals); + const amounts: bigint[] = [0n]; + + // Small amounts (dust to modest) + for (let i = 1; i <= 10; i++) { + amounts.push(unit * BigInt(i)); + } + // Medium amounts (10–10k stepping by 50) + for (let i = 50; i <= 10_000; i += 50) { + amounts.push(unit * BigInt(i)); + } + // Large amounts to stress tick crossings + for (const m of [ + 50_000n, + 100_000n, + 500_000n, + 1_000_000n, + 5_000_000n, + 10_000_000n, + 50_000_000n, + ]) { + amounts.push(unit * m); + } -const [token0] = sortTokens( - srcToken.address.toLowerCase(), - destToken.address.toLowerCase(), -); + return amounts; +} -const zeroForOne = token0 === srcToken.address.toLowerCase() ? true : false; +const sortTokens = (a: string, b: string) => + [a, b].sort((x, y) => (x < y ? -1 : 1)); -const executeGetPricesVolume = async (blockNumber: number) => { - await uniV3.getPricesVolume(srcToken, destToken, amounts, side, blockNumber); -}; +const aggregateAndPrintMeasures = (measures: number[], label: string) => { + const sorted = [...measures].sort((a, b) => a - b); + const sum = sorted.reduce((a, b) => a + b); + const avg = sum / sorted.length; + const p50 = sorted[Math.floor(sorted.length * 0.5)]; + const p95 = sorted[Math.floor(sorted.length * 0.95)]; + const p99 = sorted[Math.floor(sorted.length * 0.99)]; + const max = sorted[sorted.length - 1]; -const executeOnlySyncOperations = async (states: DeepReadonly[]) => { - await Promise.all( - states.map(async state => { - await uniswapV3Math.queryOutputs(state, amounts, zeroForOne, side); - }), + logger.info( + ` [${label}] avg=${avg.toFixed(3)}ms | p50=${p50.toFixed(3)}ms | ` + + `p95=${p95.toFixed(3)}ms | p99=${p99.toFixed(3)}ms | max=${max.toFixed( + 3, + )}ms`, ); }; -const aggregateAndPrintMeasures = (measures: number[]) => { - const sum = measures.reduce((a, b) => a + b); +let totalJsMs = 0; +let totalRustMs = 0; + +async function benchmarkPair( + pairConfig: (typeof pairs)[0], + blockNumber: number, +) { + const { name, src, dest } = pairConfig; + const amounts = generateAmounts(src.decimals); + + logger.info(`\n${'='.repeat(60)}`); + logger.info(`PAIR: ${name} (${amounts.length} amounts, ${runsNumber} runs)`); + logger.info(`${'='.repeat(60)}\n`); + + // Initialize pools + await uniV3.getPricesVolume(src, dest, amounts, side, blockNumber); + + const [token0] = sortTokens( + src.address.toLowerCase(), + dest.address.toLowerCase(), + ); + const zeroForOne = token0 === src.address.toLowerCase(); + + // Collect pool states + const poolEntries = Object.entries(uniV3.eventPools) + .filter(([key]) => { + const lower0 = src.address.toLowerCase(); + const lower1 = dest.address.toLowerCase(); + const [sorted0, sorted1] = sortTokens(lower0, lower1); + return key.includes(sorted0) && key.includes(sorted1); + }) + .filter(([, ep]) => ep != null) + .map(([key, ep]) => ({ + key: key.split('_').pop()!, // just the fee tier + state: ep!.getState(blockNumber)!, + })) + .filter(p => p.state !== null); + + if (poolEntries.length === 0) { + logger.warn(` No pools found for ${name}`); + return; + } + logger.info( - `Measured ${measures.length}. Average = ${(sum / measures.length).toFixed( - 2, - )} ms. Max = ${Math.max(...measures)} ms. Min = ${Math.min( - ...measures, - )} ms. `, + ` Pools: ${poolEntries.map(p => `fee=${p.key}`).join(', ')} (${ + poolEntries.length + } total)\n`, ); -}; -const runOneSuite = async (func: Function) => { - let counter = 0; - const measures = []; + // Count ticks per pool to understand complexity + for (const p of poolEntries) { + const tickCount = Object.keys(p.state.ticks).length; + const bitmapCount = Object.keys(p.state.tickBitmap).length; + logger.info( + ` fee=${p.key}: ${tickCount} ticks, ${bitmapCount} bitmap words, liquidity=${p.state.liquidity}`, + ); + } + logger.info(''); + + // --- JS benchmark per pool --- + logger.info(' --- JS (per pool) ---'); + for (const pool of poolEntries) { + const measures: number[] = []; + for (let i = 0; i < runsNumber; i++) { + const start = performance.now(); + uniswapV3Math.queryOutputs(pool.state, amounts, zeroForOne, side); + const elapsed = performance.now() - start; + measures.push(elapsed); + totalJsMs += elapsed; + } + aggregateAndPrintMeasures(measures, `JS fee=${pool.key}`); + } - while (counter < runsNumber) { - const start = Date.now(); + // --- Rust benchmark per pool --- + if (nativeAddonAvailable) { + logger.info(' --- Rust (per pool) ---'); + for (const pool of poolEntries) { + const handle = createRustHandle(pool.state); + if (!handle) { + logger.warn(` Failed to create Rust handle for fee=${pool.key}`); + continue; + } - await func(); + const measures: number[] = []; + for (let i = 0; i < runsNumber; i++) { + const start = performance.now(); + handle.queryOutputs(amounts, zeroForOne, 0); + const elapsed = performance.now() - start; + measures.push(elapsed); + totalRustMs += elapsed; + } + aggregateAndPrintMeasures(measures, `Rust fee=${pool.key}`); - const elapsed = Date.now() - start; - measures.push(elapsed); - if (measures.length % printFrequency === 0) { - aggregateAndPrintMeasures(measures); + // Correctness check + const jsResult = uniswapV3Math.queryOutputs( + pool.state, + amounts, + zeroForOne, + side, + ); + const rustResult = handle.queryOutputs(amounts, zeroForOne, 0); + let mismatches = 0; + for (let k = 0; k < amounts.length; k++) { + if (jsResult.outputs[k] !== rustResult.outputs[k]) { + mismatches++; + if (mismatches <= 3) { + logger.error( + ` MISMATCH fee=${pool.key} amount[${k}]=${amounts[k]} ` + + `js=${jsResult.outputs[k]} rust=${rustResult.outputs[k]}`, + ); + } + } + } + if (mismatches === 0) { + logger.info(` fee=${pool.key}: all ${amounts.length} outputs match ✓`); + } else { + logger.error( + ` fee=${pool.key}: ${mismatches}/${amounts.length} MISMATCHES`, + ); + } } - counter++; } - logger.info('\n'); - aggregateAndPrintMeasures(measures); -}; +} (async function main() { - logger.info(`Started measurement script for ${runsNumber} runs...\n`); + logger.info(`Started multi-pair benchmark (${runsNumber} runs each)\n`); const blockNumber = await dexHelper.web3Provider.eth.getBlockNumber(); + logger.info(`Block: ${blockNumber}\n`); - // Fetch all states and calculation variables before measurement - await executeGetPricesVolume(blockNumber); - - logger.info('\n'); - - const states = Object.values(uniV3.eventPools).map( - ep => ep!.getState(blockNumber)!, - ); + for (const pair of pairs) { + try { + await benchmarkPair(pair, blockNumber); + } catch (e) { + logger.error(`Failed to benchmark ${pair.name}:`, e); + } + } - logger.info(`\nRun for full calculation cycles\n`); - await runOneSuite(executeGetPricesVolume.bind(undefined, blockNumber)); + if (!nativeAddonAvailable) { + logger.info( + '\nRust addon not available. Build with: cd native && npm run build', + ); + } - logger.info(`\nRun for only sync cycles\n`); - await runOneSuite(executeOnlySyncOperations.bind(undefined, states)); + logger.info(`\n${'='.repeat(60)}`); + logger.info('TOTALS'); + logger.info(`${'='.repeat(60)}`); + logger.info(` JS total: ${totalJsMs.toFixed(3)}ms`); + if (nativeAddonAvailable) { + logger.info(` Rust total: ${totalRustMs.toFixed(3)}ms`); + logger.info(` Speedup: ${(totalJsMs / totalRustMs).toFixed(2)}x`); + } - logger.info(`Tests ended`); + logger.info(`\nBenchmark complete.`); + process.exit(0); })(); diff --git a/src/dex/uniswap-v3/types.ts b/src/dex/uniswap-v3/types.ts index 8f0aae9cd7..3b926ddad3 100644 --- a/src/dex/uniswap-v3/types.ts +++ b/src/dex/uniswap-v3/types.ts @@ -70,6 +70,7 @@ export type UniswapV3Data = { currentFee?: NumberAsString; }[]; isApproved?: boolean; + useRust?: boolean; }; export type DecodeStateMultiCallFunc = ( diff --git a/src/dex/uniswap-v3/uniswap-v3-pool.ts b/src/dex/uniswap-v3/uniswap-v3-pool.ts index 05d76cb3b0..85f1d5d7f6 100644 --- a/src/dex/uniswap-v3/uniswap-v3-pool.ts +++ b/src/dex/uniswap-v3/uniswap-v3-pool.ts @@ -30,6 +30,10 @@ import { TickBitMap } from './contract-math/TickBitMap'; import { uint256ToBigInt } from '../../lib/decoders'; import { decodeStateMultiCallResultWithRelativeBitmaps } from './utils'; import { _reduceTickBitmap, _reduceTicks } from './contract-math/utils'; +import { + registrySetPool, + RustPoolRegistryType, +} from './contract-math/native-bridge'; export class UniswapV3EventPool extends StatefulEventSubscriber { handlers: { @@ -58,6 +62,8 @@ export class UniswapV3EventPool extends StatefulEventSubscriber { public initFailed = false; public initRetryAttemptCount = 0; + public registry: RustPoolRegistryType | null = null; + public feeCodeAsString; constructor( @@ -290,6 +296,9 @@ export class UniswapV3EventPool extends StatefulEventSubscriber { // ); // } super._setState(state, blockNumber); + if (this.registry && state) { + registrySetPool(this.registry, this.name, state); + } } async generateState(blockNumber: number): Promise> { diff --git a/src/dex/uniswap-v3/uniswap-v3.ts b/src/dex/uniswap-v3/uniswap-v3.ts index 844cf4c610..e49b4f8d15 100644 --- a/src/dex/uniswap-v3/uniswap-v3.ts +++ b/src/dex/uniswap-v3/uniswap-v3.ts @@ -15,6 +15,7 @@ import { PreprocessTransactionOptions, SimpleExchangeParam, Token, + TransferFeeParams, TxInfo, } from '../../types'; import { CACHE_PREFIX, Network, SwapSide } from '../../constants'; @@ -62,6 +63,11 @@ import { } from './constants'; import { assert, DeepReadonly } from 'ts-essentials'; import { uniswapV3Math } from './contract-math/uniswap-v3-math'; +import { + createRegistry, + registrySetPool, + RustPoolRegistryType, +} from './contract-math/native-bridge'; import { Contract } from 'web3-eth-contract'; import { AbiItem } from 'web3-utils'; import { OptimalSwapExchange } from '@paraswap/core'; @@ -107,6 +113,8 @@ export class UniswapV3 readonly hasConstantPriceLargeAmounts = false; readonly needWrapNative = true; + public readonly registry: RustPoolRegistryType | null = createRegistry(); + readonly directSwapIface = new Interface(DirectSwapABI); intervalTask?: NodeJS.Timeout; @@ -171,6 +179,12 @@ export class UniswapV3 `${CACHE_PREFIX}_${network}_${dexKey}_not_existings_pool_set`.toLowerCase(); this.factory = this.getFactoryInstance(); + + this.logger.info( + `${dexKey}: native Rust math ${ + this.registry ? 'enabled' : 'not available' + }`, + ); } get supportedFees() { @@ -367,6 +381,7 @@ export class UniswapV3 this.logger.trace(`starting to listen to new pool: ${key}`); const pool = existingPool || this.getPoolInstance(token0, token1, fee, tickSpacing); + pool.registry = this.registry; let result: UniswapV3EventPool | null = pool; @@ -707,6 +722,9 @@ export class UniswapV3 side: SwapSide, blockNumber: number, limitPools?: string[], + transferFees?: TransferFeeParams, + isFirstSwap?: boolean, + useRust?: boolean, ): Promise> { try { const _srcToken = this.dexHelper.config.wrapETH(srcToken); @@ -813,76 +831,147 @@ export class UniswapV3 const zeroForOne = token0 === _srcAddress ? true : false; - const result = await Promise.all( - poolsToUse.poolWithState.map(async (pool, i) => { - const state = states[i]; - - if (state.liquidity <= 0n) { - if (state.liquidity < 0) { - this.logger.error( - `${this.dexKey}-${this.network}: ${pool.poolAddress} pool has negative liquidity: ${state.liquidity}. Find with key: ${pool.mapKey}`, - ); - } - this.logger.trace(`pool have 0 liquidity`); - return null; + // Filter eligible pools (positive liquidity) + const eligible: { + pool: UniswapV3EventPool; + state: DeepReadonly; + idx: number; + }[] = []; + for (let i = 0; i < poolsToUse.poolWithState.length; i++) { + const pool = poolsToUse.poolWithState[i]; + const state = states[i]; + if (state.liquidity <= 0n) { + if (state.liquidity < 0) { + this.logger.error( + `${this.dexKey}-${this.network}: ${pool.poolAddress} pool has negative liquidity: ${state.liquidity}. Find with key: ${pool.mapKey}`, + ); } + this.logger.trace(`pool have 0 liquidity`); + continue; + } + eligible.push({ pool, state, idx: i }); + } - const balanceDestToken = - _destAddress === pool.token0 ? state.balance0 : state.balance1; + // Batch query via registry (parallel Rust) or fallback to per-pool + const useBatchRust = + useRust !== false && this.registry && eligible.length > 1; + let batchUnitResults: Map | null = null; + let batchPriceResults: Map | null = null; + + if (useBatchRust) { + const poolKeys = eligible.map(e => e.pool.name); + const sideNum = side === SwapSide.SELL ? 0 : 1; + try { + const unitRaw = this.registry!.queryMany( + poolKeys, + [unitAmount], + zeroForOne, + sideNum, + ); + const priceRaw = this.registry!.queryMany( + poolKeys, + _amounts, + zeroForOne, + sideNum, + ); + batchUnitResults = new Map(unitRaw.map(r => [r.key, r])); + batchPriceResults = new Map(priceRaw.map(r => [r.key, r])); + } catch (e) { + this.logger.debug( + 'Batch Rust query failed, falling back to per-pool', + e, + ); + } + } - const unitResult = this._getOutputs( + const result = eligible.map(({ pool, state }) => { + const balanceDestToken = + _destAddress === pool.token0 ? state.balance0 : state.balance1; + const poolKey = pool.name; + + let unitResult: OutputResult | null = null; + let pricesResult: OutputResult | null = null; + let usedRust = false; + + // Try batch results first + if (batchUnitResults && batchPriceResults) { + const ur = batchUnitResults.get(poolKey); + const pr = batchPriceResults.get(poolKey); + if (ur && ur.outputs.length > 0 && pr && pr.outputs.length > 0) { + unitResult = this._applyBalanceCap( + ur, + [unitAmount], + side, + balanceDestToken, + ); + pricesResult = this._applyBalanceCap( + pr, + _amounts, + side, + balanceDestToken, + ); + usedRust = true; + } + } + + // Fallback to per-pool JS math + if (!unitResult || !pricesResult) { + unitResult = this._getOutputs( state, [unitAmount], zeroForOne, side, balanceDestToken, ); - const pricesResult = this._getOutputs( + pricesResult = this._getOutputs( state, _amounts, zeroForOne, side, balanceDestToken, ); + } - if (!unitResult || !pricesResult) { - this.logger.debug('Prices or unit is not calculated'); - return null; - } + if (!unitResult || !pricesResult) { + this.logger.debug('Prices or unit is not calculated'); + return null; + } - const prices = [0n, ...pricesResult.outputs]; - const gasCost = [ - 0, - ...pricesResult.outputs.map((p, index) => { - if (p == 0n) { - return 0; - } else { - return ( - UNISWAPV3_POOL_SEARCH_OVERHEAD + - UNISWAPV3_TICK_BASE_OVERHEAD + - pricesResult.tickCounts[index] * UNISWAPV3_TICK_GAS_COST - ); - } - }), - ]; - return { - unit: unitResult.outputs[0], - prices, - data: this.prepareData(_srcAddress, _destAddress, pool, state), - poolIdentifiers: [ - this.getPoolIdentifier( - pool.token0, - pool.token1, - pool.feeCode, - pool.tickSpacing, - ), - ], - exchange: this.dexKey, - gasCost: gasCost, - poolAddresses: [pool.poolAddress], - }; - }), - ); + const prices = [0n, ...pricesResult.outputs]; + const gasCost = [ + 0, + ...pricesResult.outputs.map((p, index) => { + if (p == 0n) { + return 0; + } else { + return ( + UNISWAPV3_POOL_SEARCH_OVERHEAD + + UNISWAPV3_TICK_BASE_OVERHEAD + + pricesResult!.tickCounts[index] * UNISWAPV3_TICK_GAS_COST + ); + } + }), + ]; + const data = this.prepareData(_srcAddress, _destAddress, pool, state); + data.useRust = usedRust; + + return { + unit: unitResult.outputs[0], + prices, + data, + poolIdentifiers: [ + this.getPoolIdentifier( + pool.token0, + pool.token1, + pool.feeCode, + pool.tickSpacing, + ), + ], + exchange: this.dexKey, + gasCost: gasCost, + poolAddresses: [pool.poolAddress], + }; + }); const rpcResults = await rpcResultsPromise; const notNullResult = result.filter( @@ -1476,15 +1565,28 @@ export class UniswapV3 zeroForOne: boolean, side: SwapSide, destTokenBalance: bigint, + rustHandle?: { + queryOutputs( + amounts: bigint[], + zeroForOne: boolean, + side: number, + ): { outputs: bigint[]; tickCounts: number[] }; + } | null, ): OutputResult | null { try { - const outputsResult = uniswapV3Math.queryOutputs( - state, - amounts, - zeroForOne, - side, - this.logger, - ); + const outputsResult = rustHandle + ? rustHandle.queryOutputs( + amounts, + zeroForOne, + side === SwapSide.SELL ? 0 : 1, + ) + : uniswapV3Math.queryOutputs( + state, + amounts, + zeroForOne, + side, + this.logger, + ); if (side === SwapSide.SELL) { if (outputsResult.outputs[0] > destTokenBalance) { @@ -1523,6 +1625,42 @@ export class UniswapV3 } } + protected _applyBalanceCap( + result: OutputResult, + amounts: bigint[], + side: SwapSide, + destTokenBalance: bigint, + ): OutputResult | null { + const outputsResult = { + outputs: [...result.outputs], + tickCounts: [...result.tickCounts], + }; + + if (side === SwapSide.SELL) { + if (outputsResult.outputs[0] > destTokenBalance) { + return null; + } + for (let i = 0; i < outputsResult.outputs.length; i++) { + if (outputsResult.outputs[i] > destTokenBalance) { + outputsResult.outputs[i] = 0n; + outputsResult.tickCounts[i] = 0; + } + } + } else { + if (amounts[0] > destTokenBalance) { + return null; + } + for (let i = 0; i < amounts.length; i++) { + if (amounts[i] > destTokenBalance) { + outputsResult.outputs[i] = 0n; + outputsResult.tickCounts[i] = 0; + } + } + } + + return outputsResult; + } + protected async _querySubgraph( query: string, variables: Object, diff --git a/src/dex/uniswap-v4/contract-math/native-bridge.ts b/src/dex/uniswap-v4/contract-math/native-bridge.ts new file mode 100644 index 0000000000..21e07266c4 --- /dev/null +++ b/src/dex/uniswap-v4/contract-math/native-bridge.ts @@ -0,0 +1,118 @@ +import { DeepReadonly } from 'ts-essentials'; +import { Pool, PoolState } from '../types'; +import { NumberAsString } from '@paraswap/core'; +import { + TICK_BITMAP_TO_USE, + TICK_BITMAP_BUFFER, + TICK_BITMAP_TO_USE_BY_CHAIN, + TICK_BITMAP_BUFFER_BY_CHAIN, +} from '../constants'; +import { TickBitMap } from './TickBitMap'; + +// Try to load the native Rust addon +let nativeAddon: any = null; +try { + nativeAddon = require('../../../../native/index.js'); +} catch { + // Native addon not available — JS fallback will be used +} + +export const nativeAddonAvailable = nativeAddon !== null; + +export type V4RegistryQueryResult = { + key: string; + outputs: bigint[]; +}; + +export type RustV4RegistryType = { + setPool(key: string, init: ReturnType): void; + removePool(key: string): void; + queryMany( + keys: string[], + amounts: bigint[], + zeroForOne: boolean, + side: number, + ): V4RegistryQueryResult[]; + poolCount(): number; +}; + +/** + * Convert a V4 PoolState + Pool to the init format expected by + * the Rust RustV4PoolRegistry addon. + */ +export function toV4RustInit( + state: DeepReadonly, + pool: DeepReadonly, + networkId?: number, +) { + const tickBitmap = Object.entries( + state.tickBitmap as Record, + ).map(([key, value]) => ({ + key: Number(key), + value, + })); + + const ticks = Object.entries( + state.ticks as Record< + NumberAsString, + { liquidityGross: bigint; liquidityNet: bigint } + >, + ).map(([key, info]) => ({ + key: Number(key), + liquidityGross: info.liquidityGross, + liquidityNet: info.liquidityNet, + })); + + const tickSpacing = BigInt(pool.key.tickSpacing); + const compressed = TickBitMap.compress(state.slot0.tick, tickSpacing); + const startTickBitmap = TickBitMap.position(compressed)[0]; + + const bitmapUse = Number( + networkId !== undefined + ? TICK_BITMAP_TO_USE_BY_CHAIN[networkId] ?? TICK_BITMAP_TO_USE + : TICK_BITMAP_TO_USE, + ); + const bitmapBuffer = Number( + networkId !== undefined + ? TICK_BITMAP_BUFFER_BY_CHAIN[networkId] ?? TICK_BITMAP_BUFFER + : TICK_BITMAP_BUFFER, + ); + + return { + sqrtPriceX96: state.slot0.sqrtPriceX96, + tick: state.slot0.tick, + protocolFee: BigInt(state.slot0.protocolFee), + lpFee: BigInt(state.slot0.lpFee), + liquidity: state.liquidity, + tickSpacing, + feeGrowthGlobal0X128: state.feeGrowthGlobal0X128, + feeGrowthGlobal1X128: state.feeGrowthGlobal1X128, + bitmapRange: bitmapBuffer + bitmapUse, + startTickBitmap, + tickBitmap, + ticks, + }; +} + +export function createV4Registry(): RustV4RegistryType | null { + if (!nativeAddonAvailable) return null; + try { + return new nativeAddon.RustV4PoolRegistry(); + } catch { + return null; + } +} + +export function v4RegistrySetPool( + registry: RustV4RegistryType, + key: string, + state: DeepReadonly, + pool: DeepReadonly, + networkId?: number, +): void { + try { + registry.setPool(key, toV4RustInit(state, pool, networkId)); + } catch { + // silently skip — pool will use JS fallback + } +} diff --git a/src/dex/uniswap-v4/scripts/test-rust-correctness.ts b/src/dex/uniswap-v4/scripts/test-rust-correctness.ts new file mode 100644 index 0000000000..71c2f2d715 --- /dev/null +++ b/src/dex/uniswap-v4/scripts/test-rust-correctness.ts @@ -0,0 +1,200 @@ +/* eslint-disable no-console */ +/* + * V4 Rust correctness test: compares JS vs Rust queryOutputs on real pool states. + * Directly instantiates pools and generates state, bypassing getPricesVolume. + */ +import * as dotenv from 'dotenv'; +dotenv.config(); +import { Network, SwapSide } from '../../../constants'; +import { DummyDexHelper } from '../../../dex-helper'; +import { UniswapV4 } from '../uniswap-v4'; +import { uniswapV4PoolMath } from '../contract-math/uniswap-v4-pool-math'; +import { + createV4Registry, + v4RegistrySetPool, +} from '../contract-math/native-bridge'; +import { performance } from 'perf_hooks'; +import { getBigIntPow } from '../../../utils'; +import { Pool } from '../types'; + +const RUNS = 1000; + +(async () => { + const dh = new DummyDexHelper(Network.MAINNET); + const bn = await dh.web3Provider.eth.getBlockNumber(); + const dex = new UniswapV4(Network.MAINNET, 'UniswapV4', dh); + + const USDC = { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + decimals: 6, + }; + const WETH = { + address: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + decimals: 18, + }; + + console.log(`Block: ${bn}\n`); + + // Initialize and discover pools + await dex.initializePricing(bn); + const ids = await dex.getPoolIdentifiers(USDC, WETH, SwapSide.SELL, bn); + console.log(`Pool identifiers: ${ids.length}`); + ids.forEach(id => console.log(` ${id}`)); + + // Wait for async state generation + console.log('\nWaiting for pool state generation...'); + await new Promise(resolve => setTimeout(resolve, 5000)); + + // Access pool manager internals to get pool states directly + const poolManager = (dex as any).poolManager; + const amounts = [ + 0n, + ...Array.from( + { length: 20 }, + (_, i) => getBigIntPow(6) * BigInt((i + 1) * 500), + ), + ]; + + const registry = createV4Registry(); + if (!registry) { + console.log('Rust addon not available'); + process.exit(1); + } + + let testedPools = 0; + + for (const poolId of ids) { + const eventPool = await poolManager.getEventPool(poolId, bn); + if (!eventPool) { + console.log(`\n${poolId.slice(0, 16)}... no event pool`); + continue; + } + + const state = eventPool.getState(bn); + if (!state) { + console.log(`\n${poolId.slice(0, 16)}... no state`); + continue; + } + + // Find the Pool object for this id + const pools: Pool[] = await poolManager.getAvailablePoolsForPair( + USDC.address.toLowerCase(), + WETH.address.toLowerCase(), + bn, + ); + const pool = pools.find((p: Pool) => p.id === poolId); + if (!pool) { + console.log(`\n${poolId.slice(0, 16)}... pool object not found`); + continue; + } + + const tickSpacing = pool.key.tickSpacing; + const fromAddress = USDC.address.toLowerCase(); + const currency0 = pool.key.currency0; + const zeroForOne = + fromAddress === currency0 || + (fromAddress === WETH.address.toLowerCase() && + currency0 === '0x0000000000000000000000000000000000000000'); + + console.log( + `\n${poolId.slice(0, 16)}... tickSpacing=${tickSpacing} ticks=${ + Object.keys(state.ticks).length + } liquidity=${state.liquidity}`, + ); + console.log( + ` sqrtPrice=${state.slot0.sqrtPriceX96} tick=${state.slot0.tick} zeroForOne=${zeroForOne}`, + ); + console.log( + ` protocolFee=${state.slot0.protocolFee} lpFee=${state.slot0.lpFee}`, + ); + + // Check if pool has hooks + const hasHooks = + pool.key.hooks !== '0x0000000000000000000000000000000000000000'; + if (hasHooks) { + console.log(` HOOKS: ${pool.key.hooks} — skipping Rust (hooks need JS)`); + continue; + } + + // JS queryOutputs + const jsOutputs = uniswapV4PoolMath.queryOutputs( + pool, + state, + amounts, + zeroForOne, + SwapSide.SELL, + ); + + // Rust queryOutputs via registry + v4RegistrySetPool(registry, poolId, state, pool); + const rustResults = registry.queryMany( + [poolId], + amounts, + zeroForOne, + 0, // SELL + ); + const rustOutputs = rustResults.find((r: any) => r.key === poolId)?.outputs; + + if (!rustOutputs) { + console.log(' Rust: no results returned'); + continue; + } + + // Compare + let mismatches = 0; + for (let i = 0; i < amounts.length; i++) { + if (jsOutputs[i] !== rustOutputs[i]) { + mismatches++; + if (mismatches <= 5) { + console.log( + ` MISMATCH[${i}] amount=${amounts[i]} js=${jsOutputs[i]} rust=${rustOutputs[i]}`, + ); + } + } + } + + if (mismatches === 0) { + console.log(` CORRECT: all ${amounts.length} outputs match`); + + // Benchmark + const jsM: number[] = []; + for (let i = 0; i < RUNS; i++) { + const s = performance.now(); + uniswapV4PoolMath.queryOutputs( + pool, + state, + amounts, + zeroForOne, + SwapSide.SELL, + ); + jsM.push(performance.now() - s); + } + + const rustM: number[] = []; + for (let i = 0; i < RUNS; i++) { + const s = performance.now(); + registry.queryMany([poolId], amounts, zeroForOne, 0); + rustM.push(performance.now() - s); + } + + const jsP50 = [...jsM] + .sort((a, b) => a - b) + [Math.floor(jsM.length * 0.5)].toFixed(3); + const rustP50 = [...rustM] + .sort((a, b) => a - b) + [Math.floor(rustM.length * 0.5)].toFixed(3); + const speedup = (parseFloat(jsP50) / parseFloat(rustP50)).toFixed(1); + + console.log( + ` JS p50=${jsP50}ms Rust p50=${rustP50}ms speedup=${speedup}x`, + ); + } else { + console.log(` ${mismatches}/${amounts.length} MISMATCHES`); + } + + testedPools++; + } + + console.log(`\nTested ${testedPools} pools`); + process.exit(0); +})(); diff --git a/src/dex/uniswap-v4/types.ts b/src/dex/uniswap-v4/types.ts index b391960741..3f2149321f 100644 --- a/src/dex/uniswap-v4/types.ts +++ b/src/dex/uniswap-v4/types.ts @@ -54,6 +54,7 @@ export type UniswapV4Data = { zeroForOne: boolean; pool: Pool; }[]; + useRust?: boolean; }; export type Pool = { diff --git a/src/dex/uniswap-v4/uniswap-v4-pool-manager.ts b/src/dex/uniswap-v4/uniswap-v4-pool-manager.ts index 54b640fa98..57ae51502a 100644 --- a/src/dex/uniswap-v4/uniswap-v4-pool-manager.ts +++ b/src/dex/uniswap-v4/uniswap-v4-pool-manager.ts @@ -40,6 +40,7 @@ import { TICK_BITMAP_TO_USE_BY_CHAIN, } from './constants'; import { IBaseHook } from './hooks/types'; +import { RustV4RegistryType } from './contract-math/native-bridge'; export class UniswapV4PoolManager extends StatefulEventSubscriber { handlers: { @@ -69,6 +70,8 @@ export class UniswapV4PoolManager extends StatefulEventSubscriber; + public v4Registry: RustV4RegistryType | null = null; + constructor( readonly dexHelper: IDexHelper, parentName: string, @@ -199,6 +202,7 @@ export class UniswapV4PoolManager extends StatefulEventSubscriber { handlers: { @@ -43,6 +47,8 @@ export class UniswapV4Pool extends StatefulEventSubscriber { stateMulticallIface: Interface; + public registry: RustV4RegistryType | null = null; + constructor( readonly dexHelper: IDexHelper, parentName: string, @@ -84,6 +90,23 @@ export class UniswapV4Pool extends StatefulEventSubscriber { await super.initialize(blockNumber, options); } + _setState(state: any, blockNumber: number, reason?: string): void { + super._setState(state, blockNumber); + if (this.registry && state) { + const pool: Pool = { + id: this.poolId, + key: { + currency0: this.token0, + currency1: this.token1, + fee: this.fee, + tickSpacing: parseInt(this.tickSpacing), + hooks: this.hooks, + }, + }; + v4RegistrySetPool(this.registry, this.poolId, state, pool, this.network); + } + } + getPoolIdentifierData(): PoolPairsInfo { return { poolId: this.poolId, diff --git a/src/dex/uniswap-v4/uniswap-v4.ts b/src/dex/uniswap-v4/uniswap-v4.ts index df76e6846f..43a542fbd3 100644 --- a/src/dex/uniswap-v4/uniswap-v4.ts +++ b/src/dex/uniswap-v4/uniswap-v4.ts @@ -30,9 +30,14 @@ import { swapExactOutputSingleCalldata, } from './encoder'; import { UniswapV4PoolManager } from './uniswap-v4-pool-manager'; +import { UniswapV4Pool } from './uniswap-v4-pool'; import { DeepReadonly } from 'ts-essentials'; import { PoolState } from './types'; import { uniswapV4PoolMath } from './contract-math/uniswap-v4-pool-math'; +import { + createV4Registry, + RustV4RegistryType, +} from './contract-math/native-bridge'; import { SwapSide } from '@paraswap/core'; import { queryAvailablePoolsForToken } from './subgraph'; import _ from 'lodash'; @@ -55,6 +60,8 @@ export class UniswapV4 extends SimpleExchange implements IDex { public static dexKeysWithNetwork: { key: string; networks: Network[] }[] = getDexKeysWithNetwork(UniswapV4Config); + public readonly v4Registry: RustV4RegistryType | null = createV4Registry(); + private wethAddress: string; private supportedHooks: IBaseHook[] = []; @@ -88,6 +95,13 @@ export class UniswapV4 extends SimpleExchange implements IDex { this.logger, this.cacheStateKey, ); + this.poolManager.v4Registry = this.v4Registry; + + this.logger.info( + `${dexKey}: native Rust math ${ + this.v4Registry ? 'enabled' : 'not available' + }`, + ); } async initializePricing(blockNumber: number) { @@ -187,6 +201,9 @@ export class UniswapV4 extends SimpleExchange implements IDex { side: SwapSide, blockNumber: number, limitPools?: string[], + transferFees?: {}, + isFirstSwap?: boolean, + useRust?: boolean, ): Promise | null> { const pools: Pool[] = await this.poolManager.getAvailablePoolsForPair( from.address.toLowerCase(), @@ -198,85 +215,179 @@ export class UniswapV4 extends SimpleExchange implements IDex { limitPools?.filter(t => pools.find(p => p.id === t)) ?? pools.map(t => t.id); - const pricesPromises = availablePools.map(async poolId => { - const pool = pools.find(p => p.id === poolId)!; - - const fromAddress = from.address.toLowerCase(); - const poolCurrency0 = pool.key.currency0; + // Resolve all event pools and their states upfront + type ResolvedPool = { + pool: Pool; + poolId: string; + zeroForOne: boolean; + eventPool: UniswapV4Pool | null; + poolState: DeepReadonly | null; + }; - const isFromEth = isETHAddress(fromAddress); - const isFromWeth = fromAddress === this.wethAddress; + const resolved: ResolvedPool[] = await Promise.all( + availablePools.map(async poolId => { + const pool = pools.find(p => p.id === poolId)!; - const currency0IsEth = poolCurrency0 === NULL_ADDRESS; - const currency0IsWeth = poolCurrency0 === this.wethAddress; + const fromAddress = from.address.toLowerCase(); + const poolCurrency0 = pool.key.currency0; - const zeroForOne = - fromAddress === poolCurrency0 || - (isFromEth && currency0IsEth) || // ETH is src and native ETH pool - (isFromEth && currency0IsWeth) || // ETH is src and WETH pool - (isFromWeth && currency0IsEth); // WETH is src and native ETH pool - // WETH is src and WETH pool is handled in fromAddress === poolCurrency0 case + const isFromEth = isETHAddress(fromAddress); + const isFromWeth = fromAddress === this.wethAddress; - const eventPool = await this.poolManager.getEventPool( - poolId, - blockNumber, - ); + const currency0IsEth = poolCurrency0 === NULL_ADDRESS; + const currency0IsWeth = poolCurrency0 === this.wethAddress; - const poolState = (await eventPool?.getState(blockNumber)) || null; + const zeroForOne = + fromAddress === poolCurrency0 || + (isFromEth && currency0IsEth) || + (isFromEth && currency0IsWeth) || + (isFromWeth && currency0IsEth); - let prices: bigint[] | null; - if (poolState !== null && poolState.isValid) { - prices = this._getOutputs( - pool, - poolState, - amounts, - zeroForOne, - side, - eventPool?.hook, - ); - } else { - this.logger.warn( - `${this.dexKey}-${this.network}: pool ${poolId} state was not found...falling back to rpc`, - ); - prices = await this.queryPriceFromRpc( - zeroForOne, - amounts, - pool, - side, + const eventPool = await this.poolManager.getEventPool( + poolId, blockNumber, ); - } - if (prices === null) { - return null; + const poolState = eventPool?.getState(blockNumber) || null; + + return { pool, poolId, zeroForOne, eventPool, poolState }; + }), + ); + + // Collect pools eligible for batch Rust query: + // must have valid state, no hook (hooks need JS evaluation), + // and all share the same zeroForOne direction + const eligible: ResolvedPool[] = []; + for (const r of resolved) { + if (r.poolState && r.poolState.isValid && !r.eventPool?.hook) { + eligible.push(r); } + } - if (prices?.every(price => price === 0n || price === 1n)) { - return null; + // Group eligible pools by zeroForOne for batch Rust queries + const useBatchRust = + useRust !== false && this.v4Registry && eligible.length > 0; + + let batchResults: Map | null = null; + + if (useBatchRust) { + const sideNum = side === SwapSide.SELL ? 0 : 1; + + // Group by zeroForOne direction + const zeroForOneKeys = eligible + .filter(e => e.zeroForOne) + .map(e => e.poolId); + const oneForZeroKeys = eligible + .filter(e => !e.zeroForOne) + .map(e => e.poolId); + + batchResults = new Map(); + + try { + if (zeroForOneKeys.length > 0) { + const raw = this.v4Registry!.queryMany( + zeroForOneKeys, + amounts, + true, + sideNum, + ); + + for (const r of raw) { + batchResults.set(r.key, r.outputs); + } + } + if (oneForZeroKeys.length > 0) { + const raw = this.v4Registry!.queryMany( + oneForZeroKeys, + amounts, + false, + sideNum, + ); + + for (const r of raw) { + batchResults.set(r.key, r.outputs); + } + } + } catch (e) { + this.logger.debug( + 'V4 batch Rust query failed, falling back to per-pool', + e, + ); + batchResults = null; } + } - return { - unit: BI_POWS[to.decimals], - prices, - data: { - path: [ - { - pool: { - id: pool.id, - key: pool.key, - }, - tokenIn: zeroForOne ? pool.key.currency0 : pool.key.currency1, - tokenOut: zeroForOne ? pool.key.currency1 : pool.key.currency0, + const pricesPromises = resolved.map( + async ({ pool, poolId, zeroForOne, eventPool, poolState }) => { + let prices: bigint[] | null = null; + let usedRust = false; + + // Try batch Rust results first + if (batchResults && !eventPool?.hook) { + const rustOutputs = batchResults.get(poolId); + if (rustOutputs && rustOutputs.length > 0) { + prices = rustOutputs as bigint[]; + usedRust = true; + } + } + + // Fallback to per-pool JS math or RPC + if (!prices) { + if (poolState !== null && poolState.isValid) { + prices = this._getOutputs( + pool, + poolState, + amounts, zeroForOne, - }, - ], - }, - poolAddresses: [this.poolManagerAddress], - exchange: this.dexKey, - gasCost: 100_000, - poolIdentifiers: [poolId], - }; - }); + side, + eventPool?.hook, + ); + } else { + this.logger.warn( + `${this.dexKey}-${this.network}: pool ${poolId} state was not found...falling back to rpc`, + ); + prices = await this.queryPriceFromRpc( + zeroForOne, + amounts, + pool, + side, + blockNumber, + ); + } + } + + if (prices === null) { + return null; + } + + if (prices.every(price => price === 0n || price === 1n)) { + return null; + } + + return { + unit: BI_POWS[to.decimals], + prices, + data: { + path: [ + { + pool: { + id: pool.id, + key: pool.key, + }, + tokenIn: zeroForOne ? pool.key.currency0 : pool.key.currency1, + tokenOut: zeroForOne ? pool.key.currency1 : pool.key.currency0, + zeroForOne, + }, + ], + useRust: usedRust, + }, + poolAddresses: [this.poolManagerAddress], + exchange: this.dexKey, + gasCost: 100_000, + poolIdentifiers: [poolId], + }; + }, + ); const prices = await Promise.all(pricesPromises); return prices.filter(res => res !== null); diff --git a/src/pricing-helper.ts b/src/pricing-helper.ts index ca34cae2d9..0f3ba47605 100644 --- a/src/pricing-helper.ts +++ b/src/pricing-helper.ts @@ -308,6 +308,7 @@ export class PricingHelper { destDexFee: 0, }, rollupL1CalldataCostToL2GasCost?: (calldataCost: number) => number, + useRust: boolean = false, ): Promise[]> { const dexPoolPrices = await Promise.all( dexKeys.map(async key => { @@ -342,6 +343,8 @@ export class PricingHelper { blockNumber, limitPools ? limitPools : undefined, transferFees, + undefined, + useRust, ) .then(poolPrices => { try {