diff --git a/.gitignore b/.gitignore index 53eaa21..58cd3dd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ +.env /target **/*.rs.bk diff --git a/Cargo.lock b/Cargo.lock index a18c272..f35c254 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,46 +14,12 @@ dependencies = [ "zerocopy", ] -[[package]] -name = "aho-corasick" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" -dependencies = [ - "memchr", -] - [[package]] name = "allocator-api2" version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" -[[package]] -name = "alloy" -version = "1.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f07655fedc35188f3c50ff8fc6ee45703ae14ef1bc7ae7d80e23a747012184e3" -dependencies = [ - "alloy-consensus", - "alloy-contract", - "alloy-core", - "alloy-eips", - "alloy-genesis", - "alloy-network", - "alloy-provider", - "alloy-pubsub", - "alloy-rpc-client", - "alloy-rpc-types", - "alloy-serde", - "alloy-signer", - "alloy-signer-local", - "alloy-transport", - "alloy-transport-http", - "alloy-transport-ws", - "alloy-trie", -] - [[package]] name = "alloy-chains" version = "0.2.34" @@ -90,7 +56,7 @@ dependencies = [ "serde", "serde_json", "serde_with", - "thiserror 2.0.18", + "thiserror", ] [[package]] @@ -120,27 +86,13 @@ dependencies = [ "alloy-network-primitives", "alloy-primitives", "alloy-provider", - "alloy-pubsub", "alloy-rpc-types-eth", "alloy-sol-types", "alloy-transport", "futures", "futures-util", "serde_json", - "thiserror 2.0.18", -] - -[[package]] -name = "alloy-core" -version = "1.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62ddde5968de6044d67af107ad835bc0069a7ca245870b94c5958a7d8712b184" -dependencies = [ - "alloy-dyn-abi", - "alloy-json-abi", - "alloy-primitives", - "alloy-rlp", - "alloy-sol-types", + "thiserror", ] [[package]] @@ -156,7 +108,7 @@ dependencies = [ "itoa", "serde", "serde_json", - "winnow 1.0.3", + "winnow", ] [[package]] @@ -169,7 +121,7 @@ dependencies = [ "alloy-rlp", "crc", "serde", - "thiserror 2.0.18", + "thiserror", ] [[package]] @@ -195,7 +147,7 @@ dependencies = [ "borsh", "k256", "serde", - "thiserror 2.0.18", + "thiserror", ] [[package]] @@ -232,22 +184,7 @@ dependencies = [ "serde", "serde_with", "sha2", - "thiserror 2.0.18", -] - -[[package]] -name = "alloy-genesis" -version = "1.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55d9d1aba3f914f0e8db9e4616ae37f3d811426d95bdccf44e47d0605ab202f6" -dependencies = [ - "alloy-eips", - "alloy-primitives", - "alloy-serde", - "alloy-trie", - "borsh", - "serde", - "serde_with", + "thiserror", ] [[package]] @@ -286,7 +223,7 @@ dependencies = [ "http", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror", "tracing", ] @@ -313,7 +250,7 @@ dependencies = [ "futures-utils-wasm", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror", ] [[package]] @@ -391,7 +328,7 @@ dependencies = [ "reqwest", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror", "tokio", "tracing", "url", @@ -527,7 +464,7 @@ dependencies = [ "serde", "serde_json", "serde_with", - "thiserror 2.0.18", + "thiserror", ] [[package]] @@ -553,23 +490,7 @@ dependencies = [ "either", "elliptic-curve", "k256", - "thiserror 2.0.18", -] - -[[package]] -name = "alloy-signer-local" -version = "1.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72cfe0be3ec5a8c1a46b2e5a7047ed41121d360d97f4405bb7c1c784880c86cb" -dependencies = [ - "alloy-consensus", - "alloy-network", - "alloy-primitives", - "alloy-signer", - "async-trait", - "k256", - "rand 0.8.6", - "thiserror 2.0.18", + "thiserror", ] [[package]] @@ -592,7 +513,6 @@ version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63ec265e5d65d725175f6ca7711c970824c90ef9c0d1f1973711d4150ee612dd" dependencies = [ - "alloy-json-abi", "alloy-sol-macro-input", "const-hex", "heck", @@ -611,14 +531,12 @@ version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89bf01077f18650876cfa682eb1f949967b5cde03f1a51c955c469d2c9b4aa67" dependencies = [ - "alloy-json-abi", "const-hex", "dunce", "heck", "macro-string", "proc-macro2", "quote", - "serde_json", "syn 2.0.117", "syn-solidity", ] @@ -630,7 +548,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "857b470ecdd2ed38beaf82ad1a38c516a8ff75266750f38b9eeed001d575241b" dependencies = [ "serde", - "winnow 1.0.3", + "winnow", ] [[package]] @@ -660,7 +578,7 @@ dependencies = [ "parking_lot", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror", "tokio", "tower", "tracing", @@ -729,7 +647,7 @@ dependencies = [ "nybbles", "serde", "smallvec", - "thiserror 2.0.18", + "thiserror", "tracing", ] @@ -745,40 +663,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "amm-math" -version = "0.1.0" -source = "git+ssh://git@github.com/KaiCode2/amm-math.git?branch=main#3270c698a081cef4ff3aaf29efb70f4bf0c45ebb" -dependencies = [ - "alloy-primitives", - "serde", - "tracing", - "uniswap_v3_math", -] - -[[package]] -name = "amms" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0699cb09d85d5f2f1698905963a1ced3ab598b9faebc638f6433c3d6bd6c3e55" -dependencies = [ - "alloy", - "arraydeque", - "async-stream", - "async-trait", - "eyre", - "futures", - "itertools 0.14.0", - "rayon", - "rug", - "serde", - "serde_json", - "thiserror 1.0.69", - "tokio", - "tracing", - "uniswap_v3_math", -] - [[package]] name = "android_system_properties" version = "0.1.5" @@ -788,18 +672,6 @@ dependencies = [ "libc", ] -[[package]] -name = "anes" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" - -[[package]] -name = "anstyle" -version = "1.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" - [[package]] name = "anyhow" version = "1.0.102" @@ -1018,7 +890,7 @@ dependencies = [ "ark-ff 0.5.0", "ark-std 0.5.0", "tracing", - "tracing-subscriber 0.2.25", + "tracing-subscriber", ] [[package]] @@ -1096,12 +968,6 @@ dependencies = [ "rand 0.8.6", ] -[[package]] -name = "arraydeque" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" - [[package]] name = "arrayref" version = "0.3.9" @@ -1191,12 +1057,6 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" -[[package]] -name = "az" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be5eb007b7cacc6c660343e96f650fedf4b5a77512399eb952ca6642cf8d13f7" - [[package]] name = "base16ct" version = "0.2.0" @@ -1388,12 +1248,6 @@ dependencies = [ "serde", ] -[[package]] -name = "cast" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" - [[package]] name = "cc" version = "1.2.64" @@ -1428,58 +1282,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "ciborium" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" -dependencies = [ - "ciborium-io", - "ciborium-ll", - "serde", -] - -[[package]] -name = "ciborium-io" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" - -[[package]] -name = "ciborium-ll" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" -dependencies = [ - "ciborium-io", - "half", -] - -[[package]] -name = "clap" -version = "4.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" -dependencies = [ - "clap_builder", -] - -[[package]] -name = "clap_builder" -version = "4.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" -dependencies = [ - "anstyle", - "clap_lex", -] - -[[package]] -name = "clap_lex" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" - [[package]] name = "const-hex" version = "1.19.1" @@ -1587,58 +1389,6 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" -[[package]] -name = "criterion" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1c047a62b0cc3e145fa84415a3191f628e980b194c2755aa12300a4e6cbd928" -dependencies = [ - "anes", - "cast", - "ciborium", - "clap", - "criterion-plot", - "itertools 0.13.0", - "num-traits", - "oorandom", - "plotters", - "rayon", - "regex", - "serde", - "serde_json", - "tinytemplate", - "walkdir", -] - -[[package]] -name = "criterion-plot" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b1bcc0dc7dfae599d84ad0b1a55f80cde8af3725da8313b528da95ef783e338" -dependencies = [ - "cast", - "itertools 0.13.0", -] - -[[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" @@ -2005,20 +1755,14 @@ dependencies = [ "alloy-sol-types", "alloy-transport", "alloy-transport-balancer", - "amm-math", - "amms", "anyhow", - "criterion", "evm-fork-cache", "foundry-fork-db", "futures", - "rayon", "revm", "serde", "tokio", - "toml", "tracing", - "tracing-subscriber 0.3.23", ] [[package]] @@ -2043,7 +1787,7 @@ dependencies = [ "revm", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror", "tokio", "tracing", ] @@ -2174,7 +1918,7 @@ dependencies = [ "revm", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror", "tokio", "tracing", "url", @@ -2311,11 +2055,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi 5.3.0", "wasip2", - "wasm-bindgen", ] [[package]] @@ -2337,16 +2079,6 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" -[[package]] -name = "gmp-mpfr-sys" -version = "1.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7db155b537cb791b133341f99f68371d86ee7fa4c79aacfbc376d72d23c70531" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - [[package]] name = "group" version = "0.13.0" @@ -2377,17 +2109,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "half" -version = "2.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" -dependencies = [ - "cfg-if", - "crunchy", - "zerocopy", -] - [[package]] name = "hashbrown" version = "0.12.3" @@ -2540,7 +2261,6 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots 1.0.7", ] [[package]] @@ -2886,12 +2606,6 @@ version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - [[package]] name = "leb128fmt" version = "0.1.0" @@ -2946,12 +2660,6 @@ dependencies = [ "hashbrown 0.15.5", ] -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - [[package]] name = "macro-string" version = "0.2.0" @@ -2963,15 +2671,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "matchers" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" -dependencies = [ - "regex-automata", -] - [[package]] name = "memchr" version = "2.8.2" @@ -3012,15 +2711,6 @@ dependencies = [ "tempfile", ] -[[package]] -name = "nu-ansi-term" -version = "0.50.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "num" version = "0.4.3" @@ -3153,12 +2843,6 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" -[[package]] -name = "oorandom" -version = "11.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" - [[package]] name = "openssl" version = "0.10.81" @@ -3398,34 +3082,6 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" -[[package]] -name = "plotters" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" -dependencies = [ - "num-traits", - "plotters-backend", - "plotters-svg", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "plotters-backend" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" - -[[package]] -name = "plotters-svg" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" -dependencies = [ - "plotters-backend", -] - [[package]] name = "potential_utf" version = "0.1.5" @@ -3545,61 +3201,6 @@ version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" -[[package]] -name = "quinn" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" -dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls", - "socket2", - "thiserror 2.0.18", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" -dependencies = [ - "bytes", - "getrandom 0.3.4", - "lru-slab", - "rand 0.9.4", - "ring", - "rustc-hash", - "rustls", - "rustls-pki-types", - "slab", - "thiserror 2.0.18", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" -dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2", - "tracing", - "windows-sys 0.60.2", -] - [[package]] name = "quote" version = "1.0.45" @@ -3707,26 +3308,6 @@ dependencies = [ "rustversion", ] -[[package]] -name = "rayon" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" -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 = "redox_syscall" version = "0.5.18" @@ -3756,29 +3337,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "regex" -version = "1.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" -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.11" @@ -3809,8 +3367,6 @@ dependencies = [ "native-tls", "percent-encoding", "pin-project-lite", - "quinn", - "rustls", "rustls-pki-types", "serde", "serde_json", @@ -3818,7 +3374,6 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-native-tls", - "tokio-rustls", "tower", "tower-http", "tower-service", @@ -3826,7 +3381,6 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots 1.0.7", ] [[package]] @@ -3918,7 +3472,7 @@ dependencies = [ "revm-primitives", "revm-state", "serde", - "thiserror 2.0.18", + "thiserror", ] [[package]] @@ -4063,18 +3617,6 @@ dependencies = [ "rustc-hex", ] -[[package]] -name = "rug" -version = "1.30.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07a8857882aec59d27254b02481c709327c13de6fad1da60bfc4f9783eaaa61e" -dependencies = [ - "az", - "gmp-mpfr-sys", - "libc", - "libm", -] - [[package]] name = "ruint" version = "1.17.2" @@ -4172,7 +3714,6 @@ version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ - "web-time", "zeroize", ] @@ -4211,15 +3752,6 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - [[package]] name = "schannel" version = "0.1.29" @@ -4412,15 +3944,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "serde_spanned" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" -dependencies = [ - "serde_core", -] - [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -4517,15 +4040,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "sharded-slab" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" -dependencies = [ - "lazy_static", -] - [[package]] name = "shlex" version = "2.0.1" @@ -4550,7 +4064,7 @@ checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" dependencies = [ "num-bigint", "num-traits", - "thiserror 2.0.18", + "thiserror", "time", ] @@ -4734,33 +4248,13 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.18", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "thiserror-impl", ] [[package]] @@ -4774,15 +4268,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "thread_local" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" -dependencies = [ - "cfg-if", -] - [[package]] name = "threadpool" version = "1.8.1" @@ -4832,16 +4317,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "tinytemplate" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" -dependencies = [ - "serde", - "serde_json", -] - [[package]] name = "tinyvec" version = "1.11.0" @@ -4944,30 +4419,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "toml" -version = "0.9.12+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" -dependencies = [ - "indexmap 2.14.0", - "serde_core", - "serde_spanned", - "toml_datetime 0.7.5+spec-1.1.0", - "toml_parser", - "toml_writer", - "winnow 0.7.15", -] - -[[package]] -name = "toml_datetime" -version = "0.7.5+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" -dependencies = [ - "serde_core", -] - [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -4984,9 +4435,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" dependencies = [ "indexmap 2.14.0", - "toml_datetime 1.1.1+spec-1.1.0", + "toml_datetime", "toml_parser", - "winnow 1.0.3", + "winnow", ] [[package]] @@ -4995,15 +4446,9 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow 1.0.3", + "winnow", ] -[[package]] -name = "toml_writer" -version = "1.1.1+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" - [[package]] name = "tower" version = "0.5.3" @@ -5081,17 +4526,6 @@ dependencies = [ "valuable", ] -[[package]] -name = "tracing-log" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" -dependencies = [ - "log", - "once_cell", - "tracing-core", -] - [[package]] name = "tracing-subscriber" version = "0.2.25" @@ -5101,24 +4535,6 @@ dependencies = [ "tracing-core", ] -[[package]] -name = "tracing-subscriber" -version = "0.3.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" -dependencies = [ - "matchers", - "nu-ansi-term", - "once_cell", - "regex-automata", - "sharded-slab", - "smallvec", - "thread_local", - "tracing", - "tracing-core", - "tracing-log", -] - [[package]] name = "try-lock" version = "0.2.5" @@ -5140,7 +4556,7 @@ dependencies = [ "rustls", "rustls-pki-types", "sha1", - "thiserror 2.0.18", + "thiserror", "utf-8", ] @@ -5192,17 +4608,6 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" -[[package]] -name = "uniswap_v3_math" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e393498a831893ce69ed6e1d06615e400bd1e8f97e9fcd113324f2d610fe6d45" -dependencies = [ - "alloy-primitives", - "eyre", - "thiserror 2.0.18", -] - [[package]] name = "untrusted" version = "0.9.0" @@ -5260,16 +4665,6 @@ dependencies = [ "libc", ] -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - [[package]] name = "want" version = "0.3.1" @@ -5416,16 +4811,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - [[package]] name = "webpki-roots" version = "0.26.11" @@ -5444,15 +4829,6 @@ dependencies = [ "rustls-pki-types", ] -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "windows-core" version = "0.62.2" @@ -5529,16 +4905,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -5556,31 +4923,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -5589,102 +4939,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - -[[package]] -name = "winnow" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" - [[package]] name = "winnow" version = "1.0.3" @@ -5807,7 +5103,7 @@ dependencies = [ "pharos", "rustc_version 0.4.1", "send_wrapper", - "thiserror 2.0.18", + "thiserror", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", diff --git a/Cargo.toml b/Cargo.toml index 1f04e75..281d4a0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,53 +16,27 @@ documentation = "https://docs.rs/evm-amm-state" [workspace] [features] -# Back-compat default: the full simulation + search pipeline and all protocols. -default = ["full-protocols", "simulation", "search", "toml"] +# The adapters pipeline over the finished protocols. +default = ["adapters", "uniswap-v2", "uniswap-v3", "balancer-v2", "solidly-v2"] # --- Dependency tiers --- -# Protocol adapters, event decoding, and cache orchestration. Always compiled; -# this marker carries no heavy deps (no `amms` / `amm-math` / `rayon`). +# Protocol-neutral adapter infrastructure (cold-start, reactive, sim, cache +# orchestration). Always compiled; this marker carries no heavy deps. adapters = [] -# Pure-Rust pool math + cache-sync initialization (`LocalAMM`, `*_pool` modules). -simulation = ["dep:amms", "dep:amm-math"] -# Parallel multi-leg route/arbitrage search. -search = ["simulation", "dep:rayon"] -# Parse AMM definitions from an `amms.toml` file. -toml = ["dep:toml"] # --- Per-protocol ADAPTER flags --- -# These gate the protocol *adapter* modules. Per-protocol gating of the -# simulation `LocalAMM` enum is deferred to the S2 `LocalAMM` rebuild. +# These gate the protocol *adapter* modules in `src/adapters/`. uniswap-v2 = ["adapters"] uniswap-v3 = ["adapters"] pancake-v3 = ["uniswap-v3"] # served by the V3-family adapter slipstream = ["uniswap-v3"] # served by the V3-family adapter -solidly-v2 = ["adapters"] balancer-v2 = ["adapters"] -balancer-v3 = ["adapters"] -curve = ["adapters"] -erc4626 = ["adapters"] -uniswap-v4 = ["adapters"] - -full-protocols = [ - "uniswap-v2", - "uniswap-v3", - "pancake-v3", - "slipstream", - "solidly-v2", - "balancer-v2", - "balancer-v3", - "curve", - "erc4626", - "uniswap-v4", -] -common-protocols = ["uniswap-v2", "uniswap-v3", "balancer-v2", "curve"] +solidly-v2 = ["adapters"] # Aerodrome / Velodrome V2 reserves pools [dependencies] -# Companion crates. Consumed as git dependencies while the sibling repos are -# private and unpublished. At publish time (order: amm-math, evm-fork-cache, -# then this crate), drop `git`/`branch` and keep the crates.io `version`. -amm-math = { git = "ssh://git@github.com/KaiCode2/amm-math.git", branch = "main", version = "0.1", optional = true } +# Companion crate. Consumed as a git dependency while the sibling repo is +# private and unpublished. At publish time drop `git`/`rev` and keep the +# crates.io `version`. # INTERIM: pinned to the `cold-start-sync` branch tip (903af3d = base 27ae8762 + # the additive cold-start module) while evm-fork-cache#12 is in review. Re-pin to # the `main` merge commit before this consumer work merges to evm-amm-state main. @@ -78,49 +52,26 @@ alloy-provider = "1.0.38" alloy-rpc-types-eth = "1.0.38" alloy-sol-types = "1.4" -amms = { version = "0.7.4", optional = true } anyhow = "1.0.98" foundry-fork-db = "0.22" futures = "0.3" -rayon = { version = "1.10", optional = true } revm = { version = "34.0", features = ["std", "serde", "optional_eip3607", "optional_no_base_fee"] } serde = { version = "1.0.228", features = ["derive"] } -toml = { version = "0.9.8", optional = true } tracing = "0.1.41" tokio = { version = "1.48.0", features = ["rt-multi-thread"] } [dev-dependencies] alloy-rpc-client = "1.0.38" alloy-transport = "1.0.38" -# Examples subscribe to a live node over WebSockets; enable the pubsub transport. +# Examples/tests subscribe to a live node over WebSockets; enable the pubsub transport. alloy-provider = { version = "1.0.38", features = ["ws"] } -criterion = { version = "0.7", features = ["html_reports"] } tokio = { version = "1.48.0", features = ["macros", "rt-multi-thread"] } -tracing-subscriber = { version = "0.3", features = ["env-filter"] } - -[[bench]] -name = "simulation" -harness = false -required-features = ["search"] - -# Examples build on the simulation/search pipeline; gate them so an adapter-only -# `--no-default-features --all-targets` build skips rather than fails on them. -[[example]] -name = "triangular_arbitrage" -required-features = ["search"] - -[[example]] -name = "programmatic_loading" -required-features = ["simulation"] +# Runnable adapters-path demo: register -> cold-start -> WS event subscribe -> +# reactive apply -> simulate_swap. Env-gated; no-ops if the RPC/WS URL is unset. [[example]] -name = "event_subscription" -required-features = ["simulation"] - -# Loads pools from examples/amms.toml; requires the optional `toml` feature. -[[example]] -name = "toml_loading" -required-features = ["toml", "simulation"] +name = "adapter_pipeline" +required-features = ["uniswap-v2"] # Adapter integration tests build against the per-protocol adapters; gate them # so the adapter-only build runs them only when those adapters are enabled. @@ -130,8 +81,24 @@ required-features = ["uniswap-v2", "uniswap-v3", "balancer-v2"] [[test]] name = "adapter_reactive" -required-features = ["uniswap-v2", "uniswap-v3", "balancer-v2"] +required-features = ["uniswap-v2", "uniswap-v3", "balancer-v2", "solidly-v2"] [[test]] name = "cold_start_adoption" -required-features = ["uniswap-v2", "uniswap-v3"] +required-features = ["uniswap-v2", "uniswap-v3", "balancer-v2", "solidly-v2"] + +[[test]] +name = "pipeline_e2e" +required-features = ["uniswap-v2", "uniswap-v3", "balancer-v2"] + +[[test]] +name = "adapter_swap_sim" +required-features = ["uniswap-v2", "uniswap-v3", "balancer-v2", "solidly-v2"] + +[[test]] +name = "adapter_swap_sim_rpc" +required-features = ["uniswap-v2", "uniswap-v3", "balancer-v2", "solidly-v2"] + +[[test]] +name = "reactive_ws_e2e" +required-features = ["uniswap-v2"] diff --git a/benches/simulation.rs b/benches/simulation.rs deleted file mode 100644 index 7ba20bb..0000000 --- a/benches/simulation.rs +++ /dev/null @@ -1,187 +0,0 @@ -//! Offline benchmarks for the simulation, event-apply, and routing hot paths. -//! -//! None of these touch RPC or the cache — they exercise the in-memory pool -//! models and the parallel routing search, which is exactly what runs on the -//! latency-sensitive path of a live bot. -//! -//! Run with: `cargo bench` - -use std::collections::HashMap; - -use alloy_primitives::{Address, LogData, U256}; -use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main}; -use std::hint::black_box; - -use amms::amms::{Token, amm::AutomatedMarketMaker, uniswap_v2::UniswapV2Pool}; -use evm_amm_state::amm_wrapper::{LocalAMM, Variant}; -use evm_amm_state::curve_pool::CurvePool; -use evm_amm_state::events::{apply_log, event_topics_for}; -use evm_amm_state::routing::{find_triangular_arbitrages, simulate_route, triangular_routes}; -use evm_amm_state::solidly_v2_pool::SolidlyV2Pool; - -const ONE: u128 = 1_000_000_000_000_000_000; - -fn token(byte: u8) -> Address { - Address::with_last_byte(byte) -} - -fn v2_pool(addr_byte: u8, t0: Address, t1: Address, r0: u128, r1: u128) -> LocalAMM { - LocalAMM::UniswapV2(UniswapV2Pool { - address: Address::with_last_byte(addr_byte), - token_a: Token::new_with_decimals(t0, 18), - token_b: Token::new_with_decimals(t1, 18), - reserve_0: r0, - reserve_1: r1, - fee: 300, - }) -} - -fn solidly_pool(t0: Address, t1: Address) -> LocalAMM { - LocalAMM::SolidlyV2(SolidlyV2Pool { - address: Address::with_last_byte(0xA0), - token_a: t0, - token_b: t1, - stable: false, - factory: Address::ZERO, - reserve_0: 1_000_000 * ONE, - reserve_1: 2_000_000 * ONE, - fee: 30, - decimals_0: 18, - decimals_1: 18, - }) -} - -fn curve_stable_pool(t0: Address, t1: Address) -> LocalAMM { - // 2-coin USDC/USDT-style stableswap (6 decimals), 1M each. - LocalAMM::Curve(CurvePool { - address: Address::with_last_byte(0xC0), - tokens: vec![t0, t1], - use_uint256: false, - reserves: vec![ - U256::from(1_000_000_000_000u128), - U256::from(1_000_000_000_000u128), - ], - a: U256::from(20_000u64), - fee: U256::from(4_000_000u64), - precision_multipliers: vec![ - U256::from(1_000_000_000_000u128), - U256::from(1_000_000_000_000u128), - ], - gamma: None, - price_scale: vec![], - fee_out: None, - }) -} - -fn bench_simulate_swap(c: &mut Criterion) { - let (a, b) = (token(1), token(2)); - let mut group = c.benchmark_group("simulate_swap"); - - let v2 = v2_pool(0x10, a, b, 1_000_000 * ONE, 3_000_000 * ONE); - let amount = U256::from(ONE); - group.bench_function("uniswap_v2", |bench| { - bench.iter(|| black_box(v2.simulate_swap(black_box(a), b, amount))) - }); - - let solidly = solidly_pool(a, b); - group.bench_function("solidly_v2", |bench| { - bench.iter(|| black_box(solidly.simulate_swap(black_box(a), b, amount))) - }); - - let curve = curve_stable_pool(a, b); - let curve_amount = U256::from(1_000_000_000u128); // 1000 units (6 dec) - group.bench_function("curve_stableswap", |bench| { - bench.iter(|| black_box(curve.simulate_swap(black_box(a), b, curve_amount))) - }); - - group.finish(); -} - -fn bench_event_apply(c: &mut Criterion) { - let (a, b) = (token(1), token(2)); - let pool_addr = Address::with_last_byte(0x10); - let topic0 = event_topics_for(Variant::UniswapV2)[0]; - - // A Uniswap-V2 Sync log with fresh reserves. - let mut data = Vec::with_capacity(64); - data.extend_from_slice(&U256::from(1_100_000u128 * ONE).to_be_bytes::<32>()); - data.extend_from_slice(&U256::from(2_900_000u128 * ONE).to_be_bytes::<32>()); - let log = alloy_rpc_types_eth::Log { - inner: alloy_primitives::Log { - address: pool_addr, - data: LogData::new_unchecked(vec![topic0], data.into()), - }, - ..Default::default() - }; - - c.bench_function("event_apply/uniswap_v2_sync", |bench| { - let mut pool = v2_pool(0x10, a, b, 1_000_000 * ONE, 3_000_000 * ONE); - bench.iter(|| { - let _ = black_box(apply_log(black_box(&mut pool), &log)); - }) - }); -} - -/// Build a fully-connected mesh of `k` tokens with one V2 pool per pair, with -/// slightly varied reserves so the market is not perfectly balanced. -fn mesh(k: u8) -> (HashMap, Address) { - let tokens: Vec
= (1..=k).map(token).collect(); - let mut pools = HashMap::new(); - let mut addr_byte = 0x20u8; - for i in 0..tokens.len() { - for j in (i + 1)..tokens.len() { - // Vary reserves deterministically to create price differences. - let r0 = (1_000_000 + (i as u128) * 7_000) * ONE; - let r1 = (1_000_000 + (j as u128) * 11_000) * ONE; - let amm = v2_pool(addr_byte, tokens[i], tokens[j], r0, r1); - pools.insert(amm.address(), amm); - addr_byte = addr_byte.wrapping_add(1); - } - } - (pools, tokens[0]) -} - -fn bench_triangular_search(c: &mut Criterion) { - let mut group = c.benchmark_group("triangular_search"); - let min_in = U256::from(ONE); - let max_in = U256::from(50_000u64) * U256::from(ONE); - - for k in [5u8, 8, 11] { - let (pools, start) = mesh(k); - let n_routes = triangular_routes(&pools, start).len(); - group.bench_with_input( - BenchmarkId::new("tokens", format!("{k}_routes_{n_routes}")), - &(pools, start), - |bench, (pools, start)| { - bench.iter(|| { - black_box(find_triangular_arbitrages( - black_box(pools), - *start, - min_in, - max_in, - )) - }) - }, - ); - } - group.finish(); -} - -fn bench_simulate_route(c: &mut Criterion) { - let (pools, start) = mesh(8); - let routes = triangular_routes(&pools, start); - let route = routes.into_iter().next().expect("at least one route"); - let amount = U256::from(ONE); - c.bench_function("simulate_route/3_leg", |bench| { - bench.iter(|| black_box(simulate_route(black_box(&pools), &route, amount))) - }); -} - -criterion_group!( - benches, - bench_simulate_swap, - bench_event_apply, - bench_triangular_search, - bench_simulate_route, -); -criterion_main!(benches); diff --git a/docs/e2e-ws1-reactive-coverage-spec.md b/docs/e2e-ws1-reactive-coverage-spec.md new file mode 100644 index 0000000..062f578 --- /dev/null +++ b/docs/e2e-ws1-reactive-coverage-spec.md @@ -0,0 +1,74 @@ +# E2E WS1 — Reactive event → cache coverage (new pipeline) + +Manager-owned spec. Top-priority workstream: strong guarantees the new pipeline's +event→storage path works. (The zero-coverage gap the readiness audit found was in +the LEGACY `src/events/`, which WS3 deletes — so the effort targets the **adapters +reactive path** we are keeping.) + +## Objective + +Comprehensive, behavior-level coverage of the adapters reactive path: decoded +events actually mutate the expected cache slots, the cold-start→reactive lifecycle +holds on a shared registry+cache, and edge/negative cases are handled. + +## Scope (WS1a — parallel-safe, independent of WS2) + +Covers **Uniswap V2 + Uniswap V3 only** (Balancer reactive is built in WS2; its +coverage lands in WS1b). All offline. + +1. **Per-event cache-mutation assertions**: for each handled event, assert the + exact resulting cache state (slot value/mask), not just "an event was emitted": + - V2 `Sync` → packed reserves at `V2_RESERVES_SLOT` (`slot_masked`, mask 224). + - V3 `Swap` → `slot0` (sqrtPriceX96/tick) + `liquidity` updates. + - V3 `Mint`/`Burn` → liquidity (and any tick/observation slots the adapter + decodes today). Assert what the adapter currently emits; if an expected + mutation is missing/wrong, that is a real bug — report it to the manager. +2. **Chained lifecycle** (the gap with no test today): one test per protocol that + runs `AdapterRegistry::cold_start` THEN ingests reactive events on the **same + registry + EvmCache**, asserting the post-event cached slot reflects the event. +3. **Edge / negative**: malformed log (wrong topic0 / bad data) → `ignored`/`error` + without mutation or panic; a multi-event batch applies all; `after_apply` + repair fires when a masked write is skipped (V2 `Sync` + `diff.has_skipped()` + → `RepairAction::VerifySlots`, mirroring `uniswap_v2.rs`); an event for an + untracked pool is routed to nothing. + +## Non-goals + +- Balancer reactive coverage and any `simulate_swap` assertions → WS1b (after WS2). +- Touching legacy `src/events/` (deleted in WS3) — add NO tests there. +- Changing reactive production code, except a minimal fix for a bug a new test + reveals — and only after flagging it to the manager. + +## Test plan + +Extend `tests/adapter_reactive.rs` (reuse its `ReactiveRuntime`/`ingest_batch` +harness + fixtures + helpers; follow existing naming). Add a `tests/pipeline_e2e.rs` +only if the chained lifecycle test does not fit the existing harness. All tests +offline (mocked fetcher / seeded cache), asserting concrete post-state. + +## Acceptance criteria + +- Every reactive event kind the V2 and V3 adapters handle has a test asserting the + exact cache mutation (or documented no-op). +- A chained cold-start→reactive test exists for V2 and for V3 on a shared + registry+cache. +- Negative/edge cases above are covered; no test is skipped or has loosened + assertions; suite is green (any red ⇒ a real bug, fixed within the minimal-fix + rule or escalated). + +## Verification + +``` +CARGO_NET_GIT_FETCH_WITH_CLI=true cargo test --test adapter_reactive --test cold_start_adoption +cargo fmt --all --check +cargo clippy --all-targets --no-deps -- -D warnings +cargo test && cargo test --no-default-features +``` + +## Risks / assumptions + +- Assumption: the V3 adapter's `decode_event` already emits slot0/liquidity + updates; WS1a characterizes and pins them. If V3 reactive is thinner than + assumed, scope is "cover what exists + report gaps," not "build V3 reactive." +- Coordinate with WS2: do not edit `balancer_v2.rs` or add Balancer reactive tests + here (WS2 owns that), to avoid conflicts. diff --git a/docs/e2e-ws2-adapter-swap-sim-spec.md b/docs/e2e-ws2-adapter-swap-sim-spec.md new file mode 100644 index 0000000..d3bfe3d --- /dev/null +++ b/docs/e2e-ws2-adapter-swap-sim-spec.md @@ -0,0 +1,113 @@ +# E2E WS2 — Finalize adapters (V2 / V3 / Balancer V2) + swap simulation + +Manager-owned spec for the keystone workstream. Implementation agent builds to it; +manager authors the acceptance tests and reviews. + +## Objective + +Make Uniswap V2, Uniswap V3, and Balancer V2 "fully finished" on the adapters +path: (1) reactive cache mutation in response to events, and (2) a **swap +simulation** that executes the swap in revm against the cold-start snapshot — +**no `amm-math` / `LocalAMM` / hand-rolled AMM math**. The deployed contract +bytecode does the math; we only build calldata, run it via `AdapterCache::call_raw`, +and decode the output. + +## Scope + +1. **Swap-sim surface** — a new `AmmAdapter` method: + ```rust + fn simulate_swap( + &self, + pool: &PoolRegistration, + cache: &mut dyn AdapterCache, + token_in: Address, + token_out: Address, + amount_in: U256, + ) -> Result; // default: Err(SimError::Unsupported(self.protocol())) + ``` + `SwapQuote { amount_out: U256 }` (extensible). Per-protocol it builds the + protocol's canonical quote calldata and runs `cache.call_raw(from = ZERO, to = + , calldata, commit = false)`, then decodes `amount_out` from the + `ExecutionResult` output. A revert/halt → `Err(SimError::Reverted)`. + - **Uniswap V3 (+ family):** `QuoterV2.quoteExactInputSingle((tokenIn, tokenOut, + amountIn, fee, sqrtPriceLimitX96=0))`. Target = the QuoterV2 contract. + - **Uniswap V2:** `UniswapV2Router02.getAmountsOut(amountIn, [tokenIn, tokenOut])`. + Target = the router. (This executes the on-chain `UniswapV2Library` math + against the warmed pair reserves — chain code, not ours.) + - **Balancer V2:** `Vault.queryBatchSwap(GIVEN_IN, [swap], assets, funds)`. + Target = the vault (from `BalancerV2Metadata.vault`). +2. **Quote-target addresses** — resolved from a `SimConfig`/registry default + (mainnet QuoterV2, Router02) with per-pool/chain override; Balancer uses the + pool's vault. The quote contract's bytecode must be in the cache: lazily + fetched against a live backend; **installed as a fixture** for offline tests. +3. **Balancer V2 reactive mutation** — currently routing-only + (`balancer_v2.rs:103-112`, `updates: Vec::new()`). On a `Swap` log for a + tracked pool, keep the pool's cached vault balances fresh so a subsequent + `simulate_swap` reflects the new state. **Preferred mechanism:** refresh + (re-verify) the cold-start-discovered vault balance slots rather than + reverse-engineering the vault's balance-mapping layout or doing lossy + event-delta arithmetic — this stays consistent with the discover-based cold + start. This requires the discovered balance slots to be reachable from + `decode_event`/`after_apply`; persist them (e.g. on `BalancerV2Metadata` or a + side field set in `finish`). The acceptance test asserts the **outcome** (a + post-`Swap` sim reflects refreshed balances), not the mechanism. + +## Non-goals + +- Curve, Solidly, Balancer V3, ERC4626, Uniswap V4 (scaffold-only — leave as is). +- Pre-warming all V3 tick/bitmap slots in cold-start (deferred A4 work). The sim + stays **correct** for tick-crossing swaps because `EvmCache` lazily fetches any + cold slot from the backend; full offline pre-warming is a follow-up. +- No `amm-math`/`LocalAMM`/`stableswap_math`/`cryptoswap_math` use. +- No new production crate dependencies (quote ABIs are local `sol!`). + +## Acceptance criteria + +1. `simulate_swap` returns a correct `amount_out` for V2, V3, and Balancer V2. +2. **Offline harness test** (manager-authored, per protocol): a pool is + cold-started into an `EvmCache`, a **mock quote contract** (installed at the + quote target, returning a deterministic value derived from the warmed pool + slots — same fixture style as `MockBalancerVault`) is used, `simulate_swap` + returns that value, fully offline (`asserter.read_q().is_empty()`). A reverting + quote target → `Err(SimError::Reverted)`. +3. **Balancer reactive test** (manager-authored): after cold-start, ingesting a + `Swap` log refreshes the discovered balance slots (the offline fetcher returns + new balances), and a subsequent `simulate_swap` reflects the change. Before + this work the `Swap` produces no cache mutation (red). +4. **RPC parity test** (manager-authored, `#[ignore]`/env-gated on `E2E_RPC_URL`): + fork at a pinned block, cold-start a known mainnet pool (e.g. a USDC/WETH V3 + 0.05% pool, a V2 pair, a Balancer weighted pool), run `simulate_swap`, and + assert it equals the **same quote executed via the provider's `eth_call` at the + same block** (on-chain ground truth). Exact match expected (identical bytecode + + state). Document the pinned block + pool addresses in the test. + +## Affected files + +- `src/adapters/traits.rs` (new method + default), `src/adapters/types.rs` + (`SwapQuote`, `SimError`), `uniswap_v2.rs`, `uniswap_v3.rs`, `balancer_v2.rs` + (impls + Balancer reactive), maybe `cache.rs`/a new `sim.rs` for shared + calldata/decoding helpers + quote-target config. New `sol!` quote ABIs. +- `tests/`: a new `adapter_swap_sim.rs` (offline harness + Balancer reactive) and + `adapter_swap_sim_rpc.rs` (gated parity); new mock-quote fixtures under + `tests/fixtures/`. + +## Verification + +``` +CARGO_NET_GIT_FETCH_WITH_CLI=true cargo test --test adapter_swap_sim --test adapter_reactive --test cold_start_adoption +cargo fmt --all --check +cargo clippy --all-targets --no-deps -- -D warnings +cargo clippy --no-default-features --features adapters,uniswap-v2,uniswap-v3,balancer-v2 --all-targets --no-deps -- -D warnings +cargo clippy --no-default-features --all-targets --no-deps -- -D warnings +cargo test && cargo test --no-default-features +RUSTDOCFLAGS="-D warnings" cargo doc --no-deps +# Manual (manager runs): E2E_RPC_URL= cargo test --test adapter_swap_sim_rpc -- --ignored +``` + +## Open questions / assumptions + +- Assumption: quote via canonical Quoter/Router/Vault entrypoints (above) is + acceptable as "swap tx" — it runs chain bytecode, not reimplemented math. +- Assumption: V3 family quote uses `fee`/`tick_spacing` from `V3Metadata`. +- `simulate_swap` takes `&mut dyn AdapterCache` (call_raw needs `&mut`); confirm no + borrow conflicts with the registry's adapter dispatch and adjust the call site. diff --git a/docs/e2e-ws3-cut-legacy-spec.md b/docs/e2e-ws3-cut-legacy-spec.md new file mode 100644 index 0000000..e904d74 --- /dev/null +++ b/docs/e2e-ws3-cut-legacy-spec.md @@ -0,0 +1,91 @@ +# E2E WS3 — Cut the legacy path + +Manager-owned spec. Make the crate adapters-only and agent-traversable by deleting +the legacy simulation/search stack. The adapters path is **fully self-contained** +(verified: zero `use crate::` in `src/adapters/`), and the legacy +stack is cleanly isolated behind the `simulation`/`search` features — so this is a +bounded deletion, not a risky untangle. + +## Delete + +All of these are legacy (behind `#[cfg(feature = "simulation")]` unless noted): +- Modules: `src/amm_wrapper.rs`, `src/balancer_pool.rs`, `src/balancer_v3_pool.rs`, + `src/cache_sync/`, `src/configured_amms.rs`, `src/cryptoswap_math.rs`, + `src/curve_pool.rs`, `src/data.rs`, `src/discovery.rs`, `src/events/`, + `src/progress.rs`, `src/slipstream_pool.rs`, `src/solidly_v2_pool.rs`, + `src/stableswap_math.rs`, `src/uniswap_v4_pool.rs`, and the inline + `balancer_math` + `profit` modules in `src/lib.rs`. +- `src/routing/` (behind `#[cfg(feature = "search")]`) — it is built on the legacy + `LocalAMM`, so it cannot survive the cut. **Treated as legacy: delete it.** (The + arbitrage search can be rebuilt later on the adapters `simulate_swap`; out of + scope here.) +- Examples (all legacy — use `EventRouter`/`LocalAMM`/`configured_amms`): + `examples/event_subscription.rs`, `triangular_arbitrage.rs`, + `programmatic_loading.rs`, `toml_loading.rs`, `examples/amms.toml`, and their + `[[example]]` entries in `Cargo.toml`. +- `Cargo.toml`: the `simulation`, `search`, `full-protocols`, and `toml` features + (and any now-orphaned protocol sub-features they aggregated), plus dependencies + used ONLY by the deleted code (e.g. the `amms` crate, the AMM-math crates, + `tracing-subscriber` if only examples used it). Determine orphans by building; + do NOT remove a dep still used by `adapters`/`tuning`/tests (e.g. `futures` is + used by `tests/reactive_ws_e2e.rs` — KEEP it as a dev-dep). + +## Keep + +- `src/adapters/` (entire), `src/tuning.rs` (always-on core), `src/lib.rs` (cleaned + of the deleted module declarations + inline modules). +- All adapter tests + fixtures: `tests/adapter_a1.rs`, `adapter_core.rs`, + `adapter_reactive.rs`, `cold_start_adoption.rs`, `adapter_swap_sim.rs`, + `adapter_swap_sim_rpc.rs`, `pipeline_e2e.rs`, `reactive_ws_e2e.rs`, `tests/fixtures/`. +- The `docs/` specs. + +## New default features + +Change `default` from `["full-protocols", "simulation", "search", "toml"]` to the +new pipeline: `["adapters", "uniswap-v2", "uniswap-v3", "balancer-v2"]` (the three +finished protocols). Keep the per-protocol + `adapters` features. Ensure +`--no-default-features` still builds (adapters core only). + +## New example (replaces the deleted ones) + +`examples/adapter_pipeline.rs` — a runnable adapters-path demo: build an +`EvmCache`, register a pool, cold-start it, subscribe to its events over a WS +endpoint, apply them through the reactive runtime, and `simulate_swap` against the +live-synced state. **Template: `tests/reactive_ws_e2e.rs`** (same plumbing). Gate +on an env var (e.g. `ETH_WS_URL`/`E2E_RPC_URL`); print a friendly message and exit +if unset (do not panic). Add its `[[example]]` entry. + +## Acceptance criteria + +- `cargo build`, `cargo test`, `cargo clippy --all-targets -- -D warnings`, and + `cargo doc -D warnings` all GREEN on the new default features. +- The full adapter test suite passes unchanged (a1 22, core 4, reactive 28, + cold_start 12, swap_sim 8, pipeline_e2e 3; live/RPC tests still `#[ignore]`). +- No source/doc/Cargo reference to any deleted module, the `amms` crate, or the + removed features. `grep -rn "cache_sync\|amm_wrapper\|configured_amms\|EventRouter\|LocalAMM"` in `src/` returns nothing. +- `cargo build --example adapter_pipeline` compiles. +- `cargo build --no-default-features` and `--no-default-features --features adapters,uniswap-v2,uniswap-v3,balancer-v2` both build. +- No behavior change to the adapters path. + +## Verification + +``` +CARGO_NET_GIT_FETCH_WITH_CLI=true cargo build --all-targets +cargo fmt --all --check +cargo clippy --all-targets --no-deps -- -D warnings +cargo clippy --no-default-features --all-targets --no-deps -- -D warnings +cargo test +cargo test --no-default-features +cargo build --example adapter_pipeline +RUSTDOCFLAGS="-D warnings" cargo doc --no-deps +grep -rn "cache_sync\|amm_wrapper\|configured_amms\|EventRouter\|LocalAMM\|stableswap_math" src/ Cargo.toml || echo "clean: no legacy refs" +``` + +## Notes / prohibitions + +- Do NOT touch `src/adapters/` logic or the adapter tests (only remove dead + references if a deleted module was imported — it is not). +- Do NOT remove a dependency still used by kept code; verify by building. +- If something in the delete-set turns out to be referenced by kept code (it + should not be, per recon), STOP and report rather than hacking around it. +- Keep the diff to deletions + the lib.rs/Cargo.toml cleanup + the one new example. diff --git a/docs/hardening-1-v3-multiword-tick-scan-spec.md b/docs/hardening-1-v3-multiword-tick-scan-spec.md new file mode 100644 index 0000000..b9320e3 --- /dev/null +++ b/docs/hardening-1-v3-multiword-tick-scan-spec.md @@ -0,0 +1,95 @@ +# Hardening #1 — Uniswap V3 multi-word adaptive tick scan (cold-start) + +Manager-owned spec. Implementation agent builds to it; manager authors the +acceptance tests and reviews. + +## Outcome + +Extend `UniswapV3ColdStartPlanner` (`src/adapters/uniswap_v3.rs`) so cold-start +warms a **bounded window of neighbouring tick-bitmap words** (and their +initialized ticks), not just the single current-tick word. This makes +moderate tick-crossing swaps fully offline-pre-warmed instead of relying on +`EvmCache`'s lazy backend fetch. + +## Current state + +3 rounds today: R1 `slot0`+`liquidity` (decodes current tick → current word +`W0`); R2 (Strict/Eager) the single current word's bitmap; R3 the `{0,3}` +`Tick.Info` slots of ticks initialized in `W0`. `HotSlotsOnly` = R1 only; +`Lazy` = R1 + defers the single bitmap word. Neighbouring words are never warmed. + +## Scope / design + +- Add a bounded radius constant, e.g. `const V3_TICK_WORD_RADIUS: i16 = 2;` + (window = `[W0 - R, W0 + R]`, i.e. `2R+1` words). Document the rationale (one + word covers 256 tick-spacings; R=2 covers ±2 words — generous for moderate + swaps while staying bounded; a true outward-adaptive scan is a future + refinement). Keep it a single named const, easy to tune. +- **R1 (Slot0Liquidity):** unchanged warm of `slot0`+`liquidity`; from the warmed + `slot0` decode `W0`, then compute the window of words `[W0-R .. W0+R]`, clamped + to the valid V3 word range for the pool's `tick_spacing` (derive from + `MIN_TICK`/`MAX_TICK = ±887272`; skip words/ticks outside). Resolve each word's + bitmap key. +- **R2 (BitmapWord, Strict/Eager):** verify **all** window bitmap keys in one + round (replaces the single-key verify). Store the window as + `Vec<(i16 word, U256 key)>`. +- **R3 (TickInfo, Strict/Eager):** for **each** warmed window word, read its + bitmap value, extract initialized ticks (bit `i` set ⇒ tick + `(word*256 + i) * tick_spacing`, skipping any tick outside `[MIN_TICK, MAX_TICK]`), + and collect the `{0,3}` `Tick.Info` slots across the whole window. Verify them + in one round. Empty ⇒ Done. +- **Policy:** `Strict`/`Eager` do the windowed scan (same radius). `HotSlotsOnly` + unchanged (R1 only — no bitmap/tick warming). `Lazy` unchanged in spirit but + defers the **window** of bitmap words (`DeferredWork::VerifySlots` over all + window bitmap keys) instead of one. +- `slot0`-cold path (`NeedsRepair(VerifySlots(slot0))` / Degraded) unchanged. + Config metadata still preserved (V3 is "preserve"). + +## Non-goals + +- True outward-adaptive scan (scan until N consecutive empty words) — future. +- Changing the reactive path, the sim, or any other adapter. +- No new production dependencies. No public-API change beyond the planner + internals (the const may be `pub(crate)`). + +## Edge cases + +- Word arithmetic must not overflow `i16`/`i32`: clamp the window to the valid + word range; compute tick indices in `i32` and skip ticks outside `±887272`. +- `tick_spacing` other than 60 (e.g. 10) — window math is in word units, so it + generalises; the test uses 60. +- A window word whose bitmap is `0` contributes no ticks (fine). +- Bounded guarantee: never verify more than `2R+1` bitmap words + their ticks. + +## Acceptance criteria (manager tests in `tests/cold_start_adoption.rs`) + +These must pass UNMODIFIED: +1. **Neighbour warm-up** (`v3_cold_start_warms_neighbouring_tick_words`): an + Eager cold-start of a V3 pool with initialized ticks in `W0`, `W0-1`, `W0+1` + warms the **bitmap-word slots of `W0±1`** and the **`Tick.Info` slots of the + neighbour-word ticks** (currently `None` ⇒ red), while still warming `W0`'s + bitmap + ticks (regression). Reaches `Ready`. +2. **HotSlotsOnly unchanged** (`v3_cold_start_hot_slots_only_skips_tick_words`): + under `HotSlotsOnly`, neighbour (and current) bitmap words are NOT warmed — + only `slot0`+`liquidity`. Pins the policy boundary. +3. The existing `v3_cold_start_ready_warms_slot0_and_liquidity`, + `v3_cold_start_missing_layout_is_unsupported`, + `v3_cold_start_failed_slot0_needs_repair`, and the `adapter_reactive`/`pipeline_e2e` + suites still pass. + +## Verification + +``` +CARGO_NET_GIT_FETCH_WITH_CLI=true cargo test --test cold_start_adoption --test adapter_reactive --test pipeline_e2e --test adapter_a1 +cargo fmt --all --check +cargo clippy --all-targets --no-deps -- -D warnings +cargo clippy --no-default-features --all-targets --no-deps -- -D warnings +cargo test && cargo test --no-default-features +RUSTDOCFLAGS="-D warnings" cargo doc --no-deps +``` + +## Assumptions + +- `MIN_TICK`/`MAX_TICK = ±887272` (Uniswap V3 constants); add them locally if not + present. Radius `R=2` unless the implementer finds a clearly better bounded + default (justify any change to the manager). diff --git a/docs/hardening-2-deferred-work-driver-spec.md b/docs/hardening-2-deferred-work-driver-spec.md new file mode 100644 index 0000000..724bef3 --- /dev/null +++ b/docs/hardening-2-deferred-work-driver-spec.md @@ -0,0 +1,77 @@ +# Hardening #2 — DeferredWork driver + +Manager-owned spec. Implementation agent builds to it; manager authors the +acceptance tests and reviews. + +## Outcome + +`AdapterRegistry::cold_start` returns `ColdStartOutcome::ReadyWithDeferred(report, +Vec)` for the `Lazy` policy (V2 defers its token slots; V3 defers +the bitmap-word window), but **nothing executes the deferred work** — so a Lazy +cold-start can never be completed. Add a driver that executes deferred work +against the cache, so a consumer can warm the deferred slots when ready. + +## Scope / design + +Add `AdapterRegistry::run_deferred(&self, deferred: &[DeferredWork], cache: &mut +dyn AdapterCache) -> Result`: +- `DeferredWork::VerifySlots(slots)` → `cache.verify_slots(slots)`; accumulate the + returned `SlotChange`s. +- `DeferredWork::Repair(RepairAction::VerifySlots(slots))` → same (warm the slots). +- `DeferredWork::ColdStart { .. }`, `DeferredWork::Custom(..)`, and any other + `Repair(..)` variant → **not executed in this item**; return them verbatim in + the outcome's `unhandled` list (honest — these belong to item #3 / future). Do + NOT silently drop them. + +`DeferredOutcome` (new, in `types.rs`): `{ verified: Vec, unhandled: +Vec }` (or a close equivalent — derive `Debug`; a small accessor or +`is_fully_handled()` helper is welcome). Errors from `verify_slots` propagate via +`Result`. + +The only `DeferredWork` variant actually produced today is `VerifySlots` (V2/V3 +`Lazy`), so this driver completes every current Lazy cold-start; the `unhandled` +list future-proofs the other variants. + +## Non-goals + +- Executing `ColdStart`/`Repair`(non-VerifySlots)/`Custom` deferred work — that + is item #3 / later (it needs repair execution + re-cold-start-by-key). +- Auto-running deferred work inside `cold_start` (that would defeat `Lazy`'s + purpose). The driver is an explicit, consumer-invoked step. +- No new production dependencies. + +## Affected files + +`src/adapters/registry.rs` (the method), `src/adapters/types.rs` +(`DeferredOutcome`), `src/adapters/mod.rs` (re-export `DeferredOutcome` if the +others are re-exported). No change to `cold_start` behavior. + +## Acceptance criteria (manager test in `tests/cold_start_adoption.rs`) + +Must pass UNMODIFIED: +- `v2_run_deferred_warms_lazy_deferred_slots`: Lazy cold-start a V2 pool → + `ReadyWithDeferred(_, deferred)` (token slots NOT warmed yet) → `registry + .run_deferred(&deferred, &mut cache)?` → the deferred token slots are now warmed + (`cached_storage_value(..).is_some()`), reserves still warm. (Currently does not + compile — `run_deferred` does not exist — which is the red state; green once + added.) +- Existing `v2_cold_start_lazy_defers_exactly_what_eager_warms` and the V3 tests + still pass. + +## Verification + +``` +CARGO_NET_GIT_FETCH_WITH_CLI=true cargo test --test cold_start_adoption --test adapter_reactive --test pipeline_e2e +cargo fmt --all --check +cargo clippy --all-targets --no-deps -- -D warnings +cargo clippy --no-default-features --all-targets --no-deps -- -D warnings +cargo test && cargo test --no-default-features +RUSTDOCFLAGS="-D warnings" cargo doc --no-deps +``` + +## Assumptions + +- `run_deferred` lives on `AdapterRegistry` (sibling of `cold_start`); it takes + `&self` (no registry mutation needed for `VerifySlots`). If the implementer + finds a cleaner home/signature, justify to the manager. The `unhandled` return + for non-`VerifySlots` variants is required (do not panic / drop). diff --git a/docs/hardening-4-solidly-v2-adapter-spec.md b/docs/hardening-4-solidly-v2-adapter-spec.md new file mode 100644 index 0000000..4b60ee5 --- /dev/null +++ b/docs/hardening-4-solidly-v2-adapter-spec.md @@ -0,0 +1,94 @@ +# Hardening #4 — Solidly V2 adapter (cold-start + reactive + swap-sim) + +Manager-owned spec. Implementation agent builds to it; manager authors the core +acceptance tests and reviews. Adds a real adapter for `ProtocolId::SolidlyV2` +(Aerodrome / Velodrome V2 style: reserves AMM with stable + volatile pools). + +## Mechanics (vs the existing Uniswap V2 adapter) + +Solidly V2 pools are reserves-based and emit `Sync`, like V2 — but: +- Reserves are **two separate `uint256` storage slots** (`reserve0`, `reserve1`), + not V2's single packed `(uint112,uint112,uint32)` slot. So no masked writes — + two plain slot writes. +- `Sync(uint256 reserve0, uint256 reserve1)` (Velodrome V2 / Aerodrome — implementer + CONFIRM the exact signature for the target fork). +- Pools are `stable` (x³y+y³x) or `volatile` (xy=k); the **pool's own** + `getAmountOut(uint256 amountIn, address tokenIn) returns (uint256)` handles both + in-EVM. Sim calls it via revm — **no math reimplementation**. + +## New types (in `types.rs` / `storage.rs`, re-exported from `mod.rs`) + +- `SolidlyStorageLayout { reserve0_slot, reserve1_slot, token0_slot, token1_slot: U256 }` + (mirrors `V3StorageLayout`'s config approach). Provide a `velodrome_v2()` default + whose slot indices the implementer determines from the **verified Aerodrome / + Velodrome V2 `Pool` contract** (and a test-friendly `new(...)`). Config-overridable. +- `SolidlyV2Metadata { token0: Option
, token1: Option
, stable: + Option, storage_layout: Option }`. +- `ProtocolMetadata::SolidlyV2(SolidlyV2Metadata)` variant. +- A `solidly-v2` cargo feature (`= ["adapters"]`), added to `default`. + +## Adapter behaviour (`src/adapters/solidly_v2.rs`, new — mirror `uniswap_v2.rs`) + +- `protocol()` = `SolidlyV2`. `event_sources`: the pool's `Sync` topic. +- `cold_start_planner`: resolve the layout from metadata (none → + `UnsupportedReason::MissingMetadata`). Verify `[reserve0_slot, reserve1_slot, + token0_slot, token1_slot]`. `reserve0`+`reserve1` are **mandatory**, classified + from their per-slot `SlotFetch` (mirror V2): both `Value` (not both zero) → + `Ready`; a `FetchFailed`/`NotAttempted` → `NeedsRepair(VerifySlots(reserve + slots))` / Degraded; genuine all-zero → a DISTINCT degenerate repair (e.g. + `PurgeSlots`), so archive-miss vs degenerate stay distinguishable (the SlotFetch + point). `HotSlotsOnly`: reserves only. `Lazy`: reserves now, defer token slots. + Metadata: merge `token0`/`token1` decoded from the warmed token slots, preserve + config `stable`/`storage_layout` (V2-style merge is fine; or preserve — choose + and document). +- `decode_event`: `Sync` → `updates: vec![StateUpdate::slot(reserve0_slot, r0), + StateUpdate::slot(reserve1_slot, r1)]` (two exact writes from the event payload, + no fetch), `UpdateQuality::ExactIfApplied`. Malformed/other topic → ignored/error. +- `after_apply`: if a write was skipped (`diff.has_skipped()`) → + `RepairAction::VerifySlots` of the reserve slots (mirror V2). +- `simulate_swap`: build `getAmountOut(amount_in, token_in)` calldata, run + `cache.call_raw(ZERO, pool_address, calldata, commit=false)` where `pool_address + = pool.key.address()`, decode `amount_out`. Revert/halt → `SimError::Reverted`. + (The pool reads its own reserves + `stable`/decimals, all warm or immutable.) + +## Acceptance criteria + +**Manager-authored** (must pass UNMODIFIED — these will be compile-red until the +new types/adapter exist, which is the red state): +1. `solidly_cold_start_ready_warms_reserves_and_tokens` (cold_start_adoption.rs): + Eager cold-start with a test `SolidlyStorageLayout`, seeded reserve0/reserve1/ + token0/token1 → `Ready`; all four slots warmed. +2. `solidly_sync_writes_both_reserve_slots_through_runtime` (adapter_reactive.rs): + a `Sync(r0, r1)` log through `ReactiveRuntime` writes `reserve0_slot == r0` and + `reserve1_slot == r1` exactly (offline, no fetch). + +**Implementation-agent-authored** (manager reviews): an offline `simulate_swap` +test with a mock Solidly pool fixture (`getAmountOut` returns a deterministic +value; assert no RPC + revert→`Reverted`); a zero-reserves degenerate repair test +(distinct from archive-miss); and a `#[ignore]` RPC-parity test (`sim == +eth_call getAmountOut` for a real Aerodrome/Velodrome V2 pool at a pinned block — +this is what validates the real `velodrome_v2()` layout + Sync/getAmountOut ABIs). + +## Verification + +``` +CARGO_NET_GIT_FETCH_WITH_CLI=true cargo test --test cold_start_adoption --test adapter_reactive --test adapter_swap_sim --test adapter_a1 --test pipeline_e2e +cargo fmt --all --check +cargo clippy --all-targets --no-deps -- -D warnings +cargo clippy --no-default-features --features adapters,uniswap-v2,uniswap-v3,balancer-v2,solidly-v2 --all-targets --no-deps -- -D warnings +cargo clippy --no-default-features --all-targets --no-deps -- -D warnings +cargo test && cargo test --no-default-features +RUSTDOCFLAGS="-D warnings" cargo doc --no-deps +# manager runs the parity test: E2E_RPC_URL= cargo test --test adapter_swap_sim_rpc -- --ignored +``` + +## Constraints / assumptions + +- Do NOT modify manager-authored tests. Confirm the Sync / getAmountOut ABIs and + the `velodrome_v2()` slot layout against the verified contract; the gated parity + test is the live check. Reference (math only, NOT storage layout): the legacy + `git show fc6c63e:src/solidly_v2_pool.rs`. +- No new production dependencies (ABIs are local `sol!`). Reuse the V2 patterns; + no unrelated churn; `cargo fmt` changed files. +- Keep exact event-sourced reserve writes (the V2-like value) — do NOT make the + reactive path refetch. diff --git a/docs/phase-a4-slice2-spec.md b/docs/phase-a4-slice2-spec.md new file mode 100644 index 0000000..47daff5 --- /dev/null +++ b/docs/phase-a4-slice2-spec.md @@ -0,0 +1,142 @@ +# Phase A4 — Slice 2: Balancer V2 cold-start (discover → verify) + +Status: implementation handoff. Manager owns the spec + the Balancer acceptance +tests in `tests/cold_start_adoption.rs`. Implementation agent owns the production +`BalancerV2ColdStartPlanner` + wiring. + +Builds on slice 1 (`AdapterColdStartPlanner` trait, `Bridge`, +`AdapterRegistry::cold_start`, `cold_start_planner` trait method). Stacks on +PR #9. + +## Goal + +Give Balancer V2 a real `cold_start_planner` — **net-new behavior** (the adapter +currently uses the `Unsupported` default). Balancer pool state is not at +predictable slots, so use **access-list discovery**: call `getPoolTokens(poolId)` +on the vault via the driver's `discover` phase, capture the touched `(vault, slot)` +pairs (`restrict_to = [vault]`), verify them in a second round, and decode the +token list from the call's return data. + +## Behavior — `BalancerV2ColdStartPlanner` + +**Factory** (`BalancerV2Adapter::cold_start_planner`): +- Resolve the vault: `BalancerV2Metadata.vault`, falling back to + `pool.state_addresses.first()`. No vault → `Err(UnsupportedReason::MissingMetadata("Balancer vault"))`. +- Resolve the poolId: `pool.key.bytes32()` (the `BalancerV2(B256)` key). None → + `Err(UnsupportedReason::Custom("Balancer V2 pool key is not bytes32-keyed"))`. +- Return `Ok(Box::new(BalancerV2ColdStartPlanner { vault, pool_id, policy, .. }))`. + +**Round 1 — discover** (`initial_plan`): +```rust +ColdStartPlan { + accounts: vec![self.vault], // ensure the vault's code before the call + discover: vec![ColdStartCall { + from: Address::ZERO, + to: self.vault, + calldata: IBalancerVault::getPoolTokensCall { poolId: self.pool_id }.abi_encode().into(), + restrict_to: Some(vec![self.vault]), + }], + ..Default::default() +} +``` + +**`on_results`:** +- Phase `Discover`: + - `let call = &results.discovered[0];` + - Decode the return data from `call.result` (revm `ExecutionResult::output()` → + `IBalancerVault::getPoolTokensCall::abi_decode_returns(..)`), storing + `tokens`/`balances` on the planner. A reverted/undecodable call → record a + repair and `Done` (treat as unsupported/degraded, not a panic). + - Collect the discovered slots: `call.access.slots` filtered to the vault + (already restricted). **Empty capture is a distinguishable signal** — if no + slots were touched, record a `discover-yielded-no-slots` repair and `Done` + (do not `Continue` into an empty no-op verify round). + - Otherwise → `phase = Verify`, `Continue(ColdStartPlan { verify: discovered_slots, ..Default })`. +- Phase `Verify`: the vault balance slots are now warm → `Done`. + +**`finish`:** +- Set `pool.metadata = BalancerV2(BalancerV2Metadata { vault: Some(vault), + pool_address: Some(addr_from_pool_id), tokens: decoded_tokens })` + (pool address = first 20 bytes of the poolId, matching Balancer's poolId + encoding). Set `pool.status = Ready`. Build the `ColdStartReport`. +- Discover-failed / empty-capture / undecodable-return → `NeedsRepair(report, ..)` + with `status = Degraded`. + +**`IBalancerVault` ABI:** add a local `sol! { ... getPoolTokens(bytes32 poolId) +returns (address[] tokens, uint256[] balances, uint256 lastChangeBlock); }` in +`balancer_v2.rs` (do NOT depend on the simulation-gated `cache_sync` copy). + +**Policy:** for this slice Balancer runs the full discover→verify flow for all +policies (balances are the hot state). `HotSlotsOnly`/`Lazy` nuances can be +refined later; do not over-engineer — but keep the planner policy-aware in shape. + +## Runtime note + +The discover phase runs `ensure_account` (async, sync-bridged) + an EVM call, so +`run_cold_start` for Balancer requires a **multi-thread tokio runtime** (the +manager tests use `#[tokio::test(flavor = "multi_thread")]`). + +## Acceptance criteria (manager tests in `tests/cold_start_adoption.rs`) + +The manager adds Balancer tests + an `install_mock_vault` helper (installs the +`tests/fixtures/mock_balancer_vault_runtime.hex` stub at the vault, seeds slots +0–4) and a `setup_cache_with_asserter` helper. These must pass UNMODIFIED: +- **Happy path** (`balancer_cold_start_discover_verify_ready`): install + `Address::ZERO` (gas beneficiary) + the vault stub; seed slots 0–4 (token0, + token1, balance0, balance1, lastChangeBlock); a `stub_fetcher` returns those + slots for the verify round. `registry.cold_start(Eager)` → `Ready`; + `metadata.tokens == [token0, token1]`; the discovered vault balance slots are + warm (`cached_storage_value(..).is_some()`); **no RPC** (`asserter.read_q().is_empty()`). +- **Missing vault** (`balancer_cold_start_missing_vault_unsupported`): a Balancer + registration with no vault and no `state_addresses` → `Unsupported`. + +## Constraints / prohibitions + +- Do not modify the manager-authored Balancer tests; make them pass. +- Do not weaken the slice-1 tests or the V2/V3 planners. +- The discover call runs on `EvmCache` inside the driver — no `AdapterCache` change. +- No new production dependencies (revm/alloy already present; `getPoolTokens` ABI + is a local `sol!`). +- Look up discovered slots from `call.access.slots` (a set); the `fetched`/`probed` + ordering caveat does not apply to discover, but treat the access list as a set. +- Run `cargo fmt` on changed files (not the manager test file). + +## Verification (run all; report output) + +``` +CARGO_NET_GIT_FETCH_WITH_CLI=true cargo test --test cold_start_adoption --test adapter_reactive --test adapter_a1 --test adapter_core +cargo fmt --all --check +cargo clippy --all-targets --no-deps -- -D warnings +cargo clippy --no-default-features --features adapters,uniswap-v2,uniswap-v3,balancer-v2 --all-targets --no-deps -- -D warnings +cargo clippy --no-default-features --all-targets --no-deps -- -D warnings +cargo test +cargo test --no-default-features --features adapters,uniswap-v2,uniswap-v3,balancer-v2 +cargo test --no-default-features +RUSTDOCFLAGS="-D warnings" cargo doc --no-deps +``` + +## Addendum — post-implementation hardening (audit-driven) + +A 23-agent adversarial audit of the slice-2 implementation (15 confirmed +findings, no high-severity bugs; the happy path verified sound) drove the +following fixes, all landed on this branch: + +- **Verify round now honors per-slot fetch outcomes (the one behavioral fix).** + `BalancerPhase::Verify` previously returned `Done` unconditionally, so an + archive miss / `FetchFailed` on a discovered balance slot still yielded + `Ready`. It now inspects `results.fetched` (like the V2/V3 planners) and, on an + unfetchable/never-attempted discovered slot, sets `BalancerRepair::BalancesUnfetched` + → `NeedsRepair(VerifySlots(discovered))` / `Degraded`. A genuine `Zero` stays + acceptable. This is the per-slot-outcome surfacing A4 exists for. +- **Empty-capture no longer purges the shared vault.** `NoSlotsDiscovered` now + repairs via `ColdStart` (re-discover) instead of `PurgeStorage(vault)`, which + on Balancer's shared singleton vault would have wiped every co-tenant pool. +- **Explicit success check before decode.** The discover arm now branches on + `call.result.is_success()` and uses `abi_decode_returns_validate`, instead of + relying on the decoder to reject a revert/halt payload. +- **Coverage + fidelity:** new acceptance tests for the revert→repair, + empty-capture→`ColdStart`, verify-slot-fetch-failure→repair, and N=3-token + paths (new `MockBalancerVault3` / `MockBalancerVaultNoSlot` / revert fixtures); + `pool_address = poolId[..20]` is now asserted (distinct leading-20/trailing-12 + poolId); the missing-vault test pins `MissingMetadata("Balancer vault")`. Doc + comments corrected from `address[2]`/`uint256[2]` to the real dynamic ABI. diff --git a/examples/adapter_pipeline.rs b/examples/adapter_pipeline.rs new file mode 100644 index 0000000..cd8cd68 --- /dev/null +++ b/examples/adapter_pipeline.rs @@ -0,0 +1,219 @@ +//! End-to-end adapters-path demo over a live WebSocket endpoint. +//! +//! Walks the full pipeline against a busy Uniswap V2 pool (USDC/WETH): +//! 1. Build an [`EvmCache`] pinned at the latest block `B0`. +//! 2. Register the pool and **cold-start** it — warm its reserves + token +//! slots into the cache from forked storage (no further RPC needed to read +//! them back). +//! 3. **Subscribe** to the pool's `Sync` logs over a `wss://` endpoint and +//! **apply them reactively** through the real reactive runtime, mutating +//! the cached reserves in place with zero storage refetch. +//! 4. **`simulate_swap`** against the live-synced cached state. +//! +//! This is the same plumbing exercised by `tests/reactive_ws_e2e.rs` +//! (`EvmCache` + `AdapterRegistry` + `AmmReactiveHandler` + `simulate_swap`), +//! packaged as a runnable demo. +//! +//! Endpoint: set `ETH_WS_URL` to a `wss://`/`ws://` URL, or `E2E_RPC_URL` to an +//! `https://`/`http://` URL (the `wss://` URL is derived from it). If neither is +//! set the example prints a hint and exits successfully — it never panics. +//! +//! ```text +//! ETH_WS_URL=wss://… cargo run --example adapter_pipeline +//! # or +//! E2E_RPC_URL=https://… cargo run --example adapter_pipeline +//! ``` + +use std::sync::Arc; +use std::time::Duration; + +use alloy_eips::{BlockId, BlockNumberOrTag}; +use alloy_network::{AnyNetwork, Ethereum}; +use alloy_primitives::{Address, U256, address, keccak256}; +use alloy_provider::{Provider, RootProvider}; +use alloy_rpc_types_eth::{Filter, Log as RpcLog}; +use anyhow::{Context, Result, anyhow}; +use evm_amm_state::adapters::storage::V2_RESERVES_SLOT; +use evm_amm_state::adapters::{ + AdapterRegistry, AmmAdapter, AmmReactiveHandler, ColdStartPolicy, PoolKey, PoolRegistration, + ProtocolMetadata, SimConfig, UniswapV2Adapter, UniswapV2Metadata, +}; +use evm_fork_cache::cache::EvmCache; +use evm_fork_cache::reactive::{ + BlockRef, ChainStatus, InputSource, ReactiveConfig, ReactiveContext, ReactiveInput, + ReactiveInputBatch, ReactiveInputRecord, ReactiveRuntime, +}; +use futures::StreamExt; + +const USDC: Address = address!("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"); +const WETH: Address = address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"); +const V2_ROUTER_02: Address = address!("7a250d5630B4cF539739dF2C5dAcb4c659F2488D"); +const V2_USDC_WETH_PAIR: Address = address!("B4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc"); + +fn mask112() -> U256 { + (U256::from(1) << 112) - U256::from(1) +} + +/// The cached `(reserve0, reserve1)` for the pair (low 224 bits of the packed +/// reserves slot; the top 32 timestamp bits are ignored). +fn cached_reserves(cache: &EvmCache) -> (U256, U256) { + let raw = cache + .cached_storage_value(V2_USDC_WETH_PAIR, V2_RESERVES_SLOT) + .unwrap_or_default(); + (raw & mask112(), (raw >> 112) & mask112()) +} + +/// Build a `ReactiveContext` from a subscription log (block + index metadata). +fn ctx_from_log(log: &RpcLog) -> ReactiveContext { + let number = log.block_number.unwrap_or_default(); + let hash = log.block_hash.unwrap_or_default(); + let block = BlockRef { + number, + hash, + parent_hash: None, + timestamp: log.block_timestamp, + }; + ReactiveContext { + chain_id: Some(1), + source: InputSource::Synthetic, + chain_status: ChainStatus::Included { + block: block.clone(), + confirmations: 0, + }, + block: Some(block), + transaction_index: log.transaction_index, + log_index: log.log_index, + } +} + +#[tokio::main(flavor = "multi_thread")] +async fn main() -> Result<()> { + // Resolve the WS endpoint from ETH_WS_URL (preferred) or derive it from + // E2E_RPC_URL. If neither is set, print a hint and exit cleanly. + let ws_url = match std::env::var("ETH_WS_URL") { + Ok(url) => url, + Err(_) => match std::env::var("E2E_RPC_URL") { + Ok(rpc) => rpc + .replacen("https://", "wss://", 1) + .replacen("http://", "ws://", 1), + Err(_) => { + println!( + "adapter_pipeline: set ETH_WS_URL=wss://… (or E2E_RPC_URL=https://…) to run \ + the live demo; skipping." + ); + return Ok(()); + } + }, + }; + + let secs: u64 = std::env::var("ADAPTER_PIPELINE_SECONDS") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(60); + + let provider = Arc::new( + RootProvider::::connect(&ws_url) + .await + .context("connect wss:// endpoint")?, + ); + + // 1. Pin the cache backend at the latest block B0. + let b0 = provider.get_block_number().await.context("latest block")?; + println!( + "[adapter_pipeline] pinned fork at B0={b0}; cold-starting V2 USDC/WETH and applying Sync \ + events for {secs}s" + ); + let mut cache = EvmCache::at_block( + provider.clone(), + BlockId::Number(BlockNumberOrTag::Number(b0)), + ) + .await; + + // 2. Register + cold-start the pair (warms reserves + token slots at B0). + let mut registration = PoolRegistration::new(PoolKey::UniswapV2(V2_USDC_WETH_PAIR)) + .with_state_address(V2_USDC_WETH_PAIR) + .with_metadata(ProtocolMetadata::UniswapV2(UniswapV2Metadata { + token0: Some(USDC), + token1: Some(WETH), + fee_bps: Some(30), + })); + { + let mut cold = AdapterRegistry::new(); + cold.register_adapter(Arc::new(UniswapV2Adapter::default()))?; + cold.cold_start(&mut registration, &mut cache, ColdStartPolicy::Eager)?; + } + let r0 = cached_reserves(&cache); + println!( + "[adapter_pipeline] cold-start reserves at B0: ({}, {})", + r0.0, r0.1 + ); + + // 3. Reactive runtime with the pair registered + its event sources wired. + let adapter = UniswapV2Adapter::default(); + let sources = adapter.event_sources(®istration); + let mut registry = AdapterRegistry::new(); + registry.register_adapter(Arc::new(UniswapV2Adapter::default()))?; + registry.register_pool(registration.clone().with_event_sources(sources))?; + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + runtime.register_handler(Arc::new(AmmReactiveHandler::new(registry)))?; + + // Subscribe topic-only to all Uniswap-V2 `Sync` events; the reactive handler + // routes each log by address, so only the registered pair's Syncs are + // applied — everything else is ignored. + let sync_topic = keccak256("Sync(uint112,uint112)"); + let filter = Filter::new().event_signature(sync_topic); + let mut stream = provider + .subscribe_logs(&filter) + .await + .context("subscribe_logs (needs a wss endpoint)")? + .into_stream(); + + let mut applied = 0u64; + let mut last_block = b0; + let deadline = tokio::time::Instant::now() + Duration::from_secs(secs); + loop { + tokio::select! { + _ = tokio::time::sleep_until(deadline) => break, + maybe_log = stream.next() => { + let Some(log) = maybe_log else { break }; + let block_n = log.block_number.unwrap_or(last_block); + let ctx = ctx_from_log(&log); + let batch = ReactiveInputBatch::new(vec![ReactiveInputRecord::new( + ReactiveInput::Log(log), + ctx, + )]); + let report = runtime.ingest_batch(&mut cache, batch)?; + if !report.applied.is_empty() { + applied += 1; + last_block = block_n; + let r = cached_reserves(&cache); + println!( + "[adapter_pipeline] applied Sync #{applied} at block {last_block}; \ + reserves ({}, {})", + r.0, r.1 + ); + } + } + } + } + + if applied == 0 { + println!( + "[adapter_pipeline] no USDC/WETH Sync events arrived in {secs}s (pool quiet or WS not \ + delivering). Simulating against the cold-start state instead." + ); + } + + // 4. Simulate a 1 USDC -> WETH swap against the (live-synced) cached state. + let amount_in = U256::from(1_000_000_u64); // 1 USDC (6 decimals) + let config = SimConfig::default().with_v2_router(V2_ROUTER_02); + let sim = adapter + .simulate_swap(®istration, &mut cache, USDC, WETH, amount_in, &config) + .map_err(|e| anyhow!("simulate_swap failed: {e}"))?; + println!( + "[adapter_pipeline] simulate_swap(1 USDC -> WETH) = {} wei WETH (over state at block {})", + sim.amount_out, last_block + ); + println!("[adapter_pipeline] done."); + Ok(()) +} diff --git a/examples/amms.toml b/examples/amms.toml deleted file mode 100644 index 08a0828..0000000 --- a/examples/amms.toml +++ /dev/null @@ -1,35 +0,0 @@ -# Example AMM definitions, grouped by chain name. -# -# TOML is an optional, convenient way to declare a working set of pools; it is -# not required by the crate. The same pools can be built in code with -# `AmmConfigEntry` (see examples/programmatic_loading.rs). Loading TOML needs -# the `toml` feature, which is enabled by default. -# -# Each `[[amms.]]` entry needs a `type` and `address`; the remaining -# fields are only consulted for the families that use them. - -[[amms.ethereum]] -type = "uniswap_v3" -address = "0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640" # USDC/WETH 0.05% -tokens = [ - "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", # USDC - "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", # WETH -] -fee_tier = 500 - -[[amms.ethereum]] -type = "uniswap_v2" -address = "0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc" # USDC/WETH -tokens = [ - "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", # USDC - "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", # WETH -] - -[[amms.ethereum]] -type = "uniswap_v3" -address = "0x60594a405d53811d3BC4766596EFD80fd545A270" # DAI/WETH 0.05% -tokens = [ - "0x6B175474E89094C44Da98b954EedeAC495271d0F", # DAI - "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", # WETH -] -fee_tier = 500 diff --git a/examples/event_subscription.rs b/examples/event_subscription.rs deleted file mode 100644 index 1ea3209..0000000 --- a/examples/event_subscription.rs +++ /dev/null @@ -1,114 +0,0 @@ -//! Keep pools current from a live log subscription, updating both the in-memory -//! models and the [`EvmCache`]. -//! -//! This wires an [`EventRouter`] to `provider.subscribe_logs`: for every swap / -//! mint / burn that arrives, the matching pool is updated in place (no RPC) and -//! the new state is mirrored back into the forked cache. Each pool family is -//! handled — V2/V3/PancakeSwap/Solidly/Curve/Balancer/ERC4626. -//! -//! Run with a WebSocket endpoint (Ctrl-C to stop): -//! -//! ```bash -//! ETH_WS_URL=wss://eth.llamarpc.com cargo run --example event_subscription -//! ``` - -use std::sync::Arc; - -use alloy_primitives::{Address, address}; -use alloy_provider::{Provider, ProviderBuilder, WsConnect, network::AnyNetwork}; -use alloy_rpc_types_eth::Filter; -use amms::amms::amm::AutomatedMarketMaker; -use evm_amm_state::configured_amms::{AmmConfigEntry, AmmType, load_configured_amms_from_entries}; -use evm_amm_state::events::{EventRouter, mirror_updates_to_cache}; -use evm_fork_cache::cache::{EvmCache, SlotObservationTracker}; -use futures::StreamExt; - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - tracing_subscriber::fmt() - .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) - .init(); - - let Ok(ws_url) = std::env::var("ETH_WS_URL") else { - eprintln!("Set ETH_WS_URL to an Ethereum mainnet WebSocket endpoint to run this example."); - return Ok(()); - }; - - let provider = Arc::new( - ProviderBuilder::new() - .network::() - .connect_ws(WsConnect::new(ws_url)) - .await?, - ); - - // Fork the current state and load a handful of busy pools. - let mut cache = EvmCache::new(provider.clone()).await; - - let weth = address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"); - let usdc = address!("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"); - let entries = vec![ - AmmConfigEntry::new( - AmmType::UniswapV3, - address!("88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640"), // USDC/WETH 0.05% - ) - .with_tokens(vec![usdc, weth]) - .with_fee_tier(500), - AmmConfigEntry::new( - AmmType::UniswapV2, - address!("B4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc"), // USDC/WETH - ) - .with_tokens(vec![usdc, weth]), - ]; - - println!("Loading {} pools...", entries.len()); - let amms = load_configured_amms_from_entries(&mut cache, &entries, Address::ZERO).await?; - let router = EventRouter::from_loaded(amms); - println!("Tracking {} pools. Subscribing to logs...", router.len()); - - // Filter by the pools' addresses and the event signatures they emit. - let addresses: Vec
= router.pools().keys().copied().collect(); - let filter = Filter::new() - .address(addresses) - .event_signature(router.subscription_topics()); - - let mut stream = provider.subscribe_logs(&filter).await?.into_stream(); - let mut observations = SlotObservationTracker::new(); - - println!("Listening for events (Ctrl-C to stop)...\n"); - while let Some(log) = stream.next().await { - match router.apply(&log) { - Ok(Some(update)) => { - // Mirror the freshly-applied state into the EVM cache so any - // EVM-level reads stay consistent with the in-memory pools. - let summary = mirror_updates_to_cache( - &mut cache, - &router, - std::slice::from_ref(&update), - &mut observations, - ); - - // The in-memory pool already reflects the event — show its price. - if let Some(amm_ref) = router.pools().get(&update.address) { - let guard = amm_ref.read().expect("lock"); - let price = guard.calculate_price(weth, usdc).ok(); - println!( - "{:.12} {:?} {:?} | 1 WETH ~= {} USDC | cache: v2={} v3={} ticks={}", - update.address, - update.variant, - update.kind, - price - .map(|p| format!("{p:.2}")) - .unwrap_or_else(|| "?".into()), - summary.v2_injected, - summary.v3_injected, - summary.ticks_injected, - ); - } - } - Ok(None) => {} - Err(e) => eprintln!("apply error: {e}"), - } - } - - Ok(()) -} diff --git a/examples/programmatic_loading.rs b/examples/programmatic_loading.rs deleted file mode 100644 index 936f741..0000000 --- a/examples/programmatic_loading.rs +++ /dev/null @@ -1,98 +0,0 @@ -//! Load AMMs programmatically (no `amms.toml`) and simulate a swap offline. -//! -//! Demonstrates that the crate does not require a config file: AMMs are defined -//! with [`AmmConfigEntry`] builders, loaded against an [`EvmCache`] forked from -//! a node, and then simulated entirely in-memory. -//! -//! Run with an Ethereum mainnet HTTP endpoint: -//! -//! ```bash -//! ETH_RPC_URL=https://eth.llamarpc.com cargo run --example programmatic_loading -//! ``` - -use std::sync::Arc; - -use alloy_primitives::{Address, U256, address}; -use alloy_provider::{ProviderBuilder, network::AnyNetwork}; -use amms::amms::amm::AutomatedMarketMaker; -use evm_amm_state::configured_amms::{AmmConfigEntry, AmmType, load_configured_amms_from_entries}; -use evm_fork_cache::cache::EvmCache; - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - tracing_subscriber::fmt() - .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) - .init(); - - let Ok(rpc_url) = std::env::var("ETH_RPC_URL") else { - eprintln!("Set ETH_RPC_URL to an Ethereum mainnet HTTP endpoint to run this example."); - return Ok(()); - }; - - // AnyNetwork is required by the EvmCache backend (foundry-fork-db). - let provider = Arc::new( - ProviderBuilder::new() - .network::() - .connect_http(rpc_url.parse()?), - ); - - // Fork from the latest block. - let mut cache = EvmCache::new(provider).await; - - // Mainnet tokens. - let weth = address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"); - let usdc = address!("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"); - let dai = address!("6B175474E89094C44Da98b954EedeAC495271d0F"); - - // Define the working set in code — this is the alternative to amms.toml. - let entries = vec![ - AmmConfigEntry::new( - AmmType::UniswapV3, - address!("88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640"), // USDC/WETH 0.05% - ) - .with_tokens(vec![usdc, weth]) - .with_fee_tier(500), - AmmConfigEntry::new( - AmmType::UniswapV2, - address!("B4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc"), // USDC/WETH - ) - .with_tokens(vec![usdc, weth]), - AmmConfigEntry::new( - AmmType::UniswapV3, - address!("60594a405d53811d3BC4766596EFD80fd545A270"), // DAI/WETH 0.05% - ) - .with_tokens(vec![dai, weth]) - .with_fee_tier(500), - ]; - - println!("Loading {} pools from the fork...", entries.len()); - let amms = load_configured_amms_from_entries(&mut cache, &entries, Address::ZERO).await?; - - let loaded: Vec<_> = amms - .iter() - .filter_map(|(a, m)| m.as_ref().map(|m| (a, m))) - .collect(); - println!("Loaded {}/{} pools.\n", loaded.len(), entries.len()); - - // Simulate 1 WETH -> USDC on each pool that supports the pair, fully offline. - let one_weth = U256::from(10u64).pow(U256::from(18u64)); - for (addr, amm) in &loaded { - if amm.tokens().contains(&weth) && amm.tokens().contains(&usdc) { - match amm.simulate_swap(weth, usdc, one_weth) { - Ok(out) => { - // USDC has 6 decimals. - let usdc_out = out / U256::from(1_000_000u64); - println!( - "pool {:.12} ({:?}): 1 WETH -> {} USDC", - addr, - amm.variant(), - usdc_out - ); - } - Err(e) => println!("pool {:.12}: swap failed: {e}", addr), - } - } - } - - Ok(()) -} diff --git a/examples/toml_loading.rs b/examples/toml_loading.rs deleted file mode 100644 index 3edc1a1..0000000 --- a/examples/toml_loading.rs +++ /dev/null @@ -1,71 +0,0 @@ -//! Load AMMs from an `amms.toml` file (the optional `toml` feature). -//! -//! TOML is one supported source of AMM definitions, not a requirement — see -//! `examples/programmatic_loading.rs` for the config-free path. This example -//! parses `examples/amms.toml`, loads the `ethereum` chain's pools against a -//! fork, and prices a pair. -//! -//! Run with: -//! -//! ```bash -//! ETH_RPC_URL=https://eth.llamarpc.com cargo run --example toml_loading -//! ``` - -use std::path::Path; -use std::sync::Arc; - -use alloy_primitives::{Address, address}; -use alloy_provider::{ProviderBuilder, network::AnyNetwork}; -use amms::amms::amm::AutomatedMarketMaker; -use evm_amm_state::configured_amms::{load_amm_config_entries, load_configured_amms_from_entries}; -use evm_fork_cache::cache::EvmCache; - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - tracing_subscriber::fmt() - .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) - .init(); - - // Parse the config first so we can inspect/filter before any RPC work. - let toml_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("examples/amms.toml"); - let entries = load_amm_config_entries("ethereum", Some(&toml_path))?; - println!( - "Parsed {} entries from {}", - entries.len(), - toml_path.display() - ); - - let Ok(rpc_url) = std::env::var("ETH_RPC_URL") else { - eprintln!("\nSet ETH_RPC_URL to an Ethereum mainnet HTTP endpoint to load and price them."); - return Ok(()); - }; - - let provider = Arc::new( - ProviderBuilder::new() - .network::() - .connect_http(rpc_url.parse()?), - ); - let mut cache = EvmCache::new(provider).await; - - let amms = load_configured_amms_from_entries(&mut cache, &entries, Address::ZERO).await?; - let loaded = amms.values().filter(|m| m.is_some()).count(); - println!("Loaded {}/{} pools from the fork.\n", loaded, entries.len()); - - let weth = address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"); - let usdc = address!("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"); - for amm in amms.values().flatten() { - if amm.tokens().contains(&weth) - && amm.tokens().contains(&usdc) - && let Ok(price) = amm.calculate_price(weth, usdc) - { - println!( - "pool {:.12} ({:?}): 1 WETH ~= {:.2} USDC", - amm.address(), - amm.variant(), - price - ); - } - } - - Ok(()) -} diff --git a/examples/triangular_arbitrage.rs b/examples/triangular_arbitrage.rs deleted file mode 100644 index d29a765..0000000 --- a/examples/triangular_arbitrage.rs +++ /dev/null @@ -1,151 +0,0 @@ -//! Offline triangular-arbitrage search driven by pool events. -//! -//! This example needs no RPC node. It builds a small synthetic set of pools, -//! wraps them in an [`EventRouter`], then: -//! -//! 1. searches for a 3-leg arbitrage and finds none (the market is balanced); -//! 2. applies a synthetic swap event that skews one pool's reserves — -//! exactly what a live log subscription would deliver — so the in-memory -//! pools immediately reflect the new state; -//! 3. takes an immutable snapshot and searches again, now in parallel and -//! fully offline, and finds the opportunity the event opened up. -//! -//! Run with: -//! -//! ```bash -//! cargo run --example triangular_arbitrage -//! ``` -//! -//! The live counterpart that subscribes to a real node is -//! `examples/event_subscription.rs`. - -use std::collections::HashMap; -use std::time::Instant; - -use alloy_primitives::{Address, LogData, U256}; -use evm_amm_state::amm_wrapper::{LocalAMM, Variant}; -use evm_amm_state::events::{EventRouter, event_topics_for}; -use evm_amm_state::routing::{Route, find_triangular_arbitrages}; - -use amms::amms::{Token, uniswap_v2::UniswapV2Pool}; - -/// 1 token, with 18 decimals. -const ONE: u128 = 1_000_000_000_000_000_000; - -fn addr(byte: u8) -> Address { - Address::with_last_byte(byte) -} - -/// Build a Uniswap-V2 pool (0.3% fee, 18-decimal tokens). -fn v2(addr_byte: u8, token0: Address, token1: Address, r0: u128, r1: u128) -> (Address, LocalAMM) { - let address = Address::with_last_byte(addr_byte); - let pool = UniswapV2Pool { - address, - token_a: Token::new_with_decimals(token0, 18), - token_b: Token::new_with_decimals(token1, 18), - reserve_0: r0, - reserve_1: r1, - fee: 300, - }; - (address, LocalAMM::UniswapV2(pool)) -} - -/// Encode a Uniswap-V2 `Sync(uint112,uint112)` log for `pool`, as a live -/// subscription would deliver it. -fn v2_sync_log(pool: Address, reserve0: u128, reserve1: u128) -> alloy_rpc_types_eth::Log { - let topic0 = event_topics_for(Variant::UniswapV2)[0]; - let mut data = Vec::with_capacity(64); - data.extend_from_slice(&U256::from(reserve0).to_be_bytes::<32>()); - data.extend_from_slice(&U256::from(reserve1).to_be_bytes::<32>()); - let inner = alloy_primitives::Log { - address: pool, - data: LogData::new_unchecked(vec![topic0], data.into()), - }; - alloy_rpc_types_eth::Log { - inner, - ..Default::default() - } -} - -fn describe_route(route: &Route, names: &HashMap) -> String { - let mut s = String::new(); - if let Some(start) = route.start_token() { - s.push_str(names.get(&start).copied().unwrap_or("?")); - } - for leg in &route.legs { - s.push_str(" -> "); - s.push_str(names.get(&leg.token_out).copied().unwrap_or("?")); - } - s -} - -fn main() { - // Three synthetic tokens forming a triangle. - let weth = addr(0x01); - let usdc = addr(0x02); - let dai = addr(0x03); - let names: HashMap = [(weth, "WETH"), (usdc, "USDC"), (dai, "DAI")] - .into_iter() - .collect(); - - // Three balanced 1:1 pools — no arbitrage to start. - let pools: HashMap = [ - v2(0x10, weth, usdc, 1_000_000 * ONE, 1_000_000 * ONE), - v2(0x11, usdc, dai, 1_000_000 * ONE, 1_000_000 * ONE), - v2(0x12, dai, weth, 1_000_000 * ONE, 1_000_000 * ONE), - ] - .into_iter() - .collect(); - - let router = EventRouter::from_amms(pools); - println!("Tracking {} pools.\n", router.len()); - - let min_in = U256::from(ONE); // 1 WETH - let max_in = U256::from(200_000) * U256::from(ONE); // up to 200k WETH - - // 1. Balanced market: no opportunity. - let before = find_triangular_arbitrages(&router.snapshot(), weth, min_in, max_in); - println!("Before event: {} profitable cycle(s).", before.len()); - - // 2. A large swap hits the DAI/WETH pool, leaving it rich in WETH. A live - // bot would receive this as a `Sync` log; here we craft it by hand and - // feed it through the same router path. - let dai_weth_pool = addr(0x12); - let log = v2_sync_log(dai_weth_pool, 1_000_000 * ONE, 2_000_000 * ONE); - match router.apply(&log) { - Ok(Some(update)) => println!( - "\nApplied event to pool {:.10} ({:?}); pools now reflect new state.", - update.address, update.kind - ), - Ok(None) => println!("\nEvent did not match any tracked pool."), - Err(e) => println!("\nFailed to apply event: {e}"), - } - - // 3. Re-search on an immutable snapshot — parallel and fully offline. - let snapshot = router.snapshot(); - let started = Instant::now(); - let results = find_triangular_arbitrages(&snapshot, weth, min_in, max_in); - let elapsed = started.elapsed(); - - println!( - "\nAfter event: {} profitable cycle(s) found in {:?} (parallel, offline).", - results.len(), - elapsed - ); - for (i, arb) in results.iter().take(5).enumerate() { - let amount_in = arb.amount_in / U256::from(ONE); - let profit = arb.profit / U256::from(ONE); - println!( - " #{}: {} | in ~{} WETH -> profit ~{} WETH", - i + 1, - describe_route(&arb.route, &names), - amount_in, - profit, - ); - } - - if let Some(best) = results.first() { - assert!(best.profit > U256::ZERO); - println!("\nBest cycle profit: {} wei", best.profit); - } -} diff --git a/src/adapters/balancer_v2.rs b/src/adapters/balancer_v2.rs index f0bd62e..d042e5f 100644 --- a/src/adapters/balancer_v2.rs +++ b/src/adapters/balancer_v2.rs @@ -1,15 +1,31 @@ -use alloy_primitives::Log; -use alloy_sol_types::{SolEvent, sol}; +use alloy_primitives::{Address, B256, Bytes, Log, U256}; +use alloy_sol_types::{SolCall, SolEvent, sol}; +use evm_fork_cache::cold_start::{ + ColdStartCall, ColdStartPlan, ColdStartResults, ColdStartRunReport, ColdStartStep, SlotFetch, +}; +use super::cold_start::AdapterColdStartPlanner; +use super::sim::{ + BatchSwapStep, FundManagement, SimConfig, SimError, SwapQuote, queryBatchSwapCall, run_quote, +}; use super::{ - AdapterEvent, AdapterEventError, AdapterEventKind, AdapterEventResult, AmmAdapter, EventSource, - PoolRegistration, ProtocolId, ProtocolMetadata, RepairAction, StateView, UpdateQuality, + AdapterCache, AdapterEvent, AdapterEventError, AdapterEventKind, AdapterEventResult, + AmmAdapter, BalancerV2Metadata, ColdStartOutcome, ColdStartPolicy, ColdStartReport, + EventSource, PoolRegistration, PoolStatus, ProtocolId, ProtocolMetadata, RepairAction, + SlotChange, StateView, UnsupportedReason, UpdateQuality, }; sol! { event Swap(bytes32 indexed poolId, address indexed tokenIn, address indexed tokenOut, uint256 amountIn, uint256 amountOut); } +sol! { + /// Local Balancer V2 vault `getPoolTokens` ABI for cold-start discovery, + /// kept beside the adapter so it compiles under the `balancer-v2` feature. + function getPoolTokens(bytes32 poolId) + returns (address[] tokens, uint256[] balances, uint256 lastChangeBlock); +} + #[derive(Clone, Debug, Default)] pub struct BalancerV2Adapter { _private: (), @@ -34,6 +50,35 @@ impl AmmAdapter for BalancerV2Adapter { .collect() } + fn cold_start_planner( + &self, + pool: &PoolRegistration, + policy: ColdStartPolicy, + ) -> Result, UnsupportedReason> { + // Resolve the vault: prefer cached metadata, fall back to the first + // registered state address. Without one there is nothing to discover on. + let vault = match &pool.metadata { + ProtocolMetadata::BalancerV2(metadata) => metadata + .vault + .or_else(|| pool.state_addresses.first().copied()), + _ => pool.state_addresses.first().copied(), + }; + let Some(vault) = vault else { + return Err(UnsupportedReason::MissingMetadata("Balancer vault")); + }; + + // The poolId is the bytes32-keyed pool identity; it drives `getPoolTokens`. + let Some(pool_id) = pool.key.bytes32() else { + return Err(UnsupportedReason::Custom( + "Balancer V2 pool key is not bytes32-keyed".into(), + )); + }; + + Ok(Box::new(BalancerV2ColdStartPlanner::new( + vault, pool_id, policy, + ))) + } + fn decode_event( &self, pool: &PoolRegistration, @@ -50,13 +95,30 @@ impl AmmAdapter for BalancerV2Adapter { )); } - // A1 proves vault-emitted routing only. We surface the swap with - // `ConservativeInvalidation` quality but intentionally emit no cache - // mutation or repair: a real invalidation needs the vault balance - // storage mapping and a conservative repair policy, both deferred to a - // later phase (see ROADMAP A3 / docs/phase-a1-tech-spec.md). Until then - // Balancer cache state is maintained by the legacy `cache_sync` path, - // not this adapter. + // The vault balances live behind a non-predictable storage mapping, so + // the Swap event payload cannot be turned into an exact masked write. + // Instead we keep the cached balances fresh by re-verifying the exact + // `(vault, slot)` pairs the cold-start `getPoolTokens` discovery found: + // a `VerifySlots` repair the reactive runtime lowers into a hash-pinned + // resync, re-reading the post-swap balances authoritatively. This stays + // consistent with the discover-based cold start and avoids lossy + // event-delta arithmetic. The discovered slots are persisted on + // `BalancerV2Metadata.balance_slots` by the cold-start `finish`. + let repair = match &pool.metadata { + ProtocolMetadata::BalancerV2(metadata) => { + match (metadata.vault, metadata.balance_slots.as_slice()) { + (Some(vault), slots) if !slots.is_empty() => { + RepairAction::VerifySlots(slots.iter().map(|slot| (vault, *slot)).collect()) + } + // Vault known but no discovered slots yet (cold-start has not + // run / found them): fall back to the conservative no-op so + // the routing/observability behavior is preserved. + _ => RepairAction::None, + } + } + _ => RepairAction::None, + }; + AdapterEventResult::event(AdapterEvent { pool: pool.key.clone(), emitter: log.address, @@ -64,7 +126,317 @@ impl AmmAdapter for BalancerV2Adapter { kind: AdapterEventKind::Swap, updates: Vec::new(), quality: UpdateQuality::ConservativeInvalidation, - repair: RepairAction::None, + repair, }) } + + /// Quote via `Vault.queryBatchSwap(GIVEN_IN, [swap], assets, funds)`. + /// + /// The vault simulates the swap against the warmed pool balances and returns + /// the signed asset deltas; the negative delta on the `tokenOut` index is + /// the (vault-paid-out) output amount, so `amount_out = -delta`. Chain code + /// does the math — there is no reimplemented stableswap/weighted formula. + fn simulate_swap( + &self, + pool: &PoolRegistration, + cache: &mut dyn AdapterCache, + token_in: Address, + token_out: Address, + amount_in: U256, + _config: &SimConfig, + ) -> Result { + let (vault, pool_id) = match (&pool.metadata, pool.key.bytes32()) { + (ProtocolMetadata::BalancerV2(metadata), Some(pool_id)) => { + let vault = metadata + .vault + .or_else(|| pool.state_addresses.first().copied()) + .ok_or(SimError::MissingMetadata("Balancer vault"))?; + (vault, pool_id) + } + (ProtocolMetadata::BalancerV2(_), None) => { + return Err(SimError::MissingMetadata("Balancer poolId")); + } + _ => return Err(SimError::MissingMetadata("Balancer metadata")), + }; + + // assets[0] = tokenIn, assets[1] = tokenOut; a single GIVEN_IN step + // swaps `amount_in` of asset 0 into asset 1 through `pool_id`. + let calldata = Bytes::from( + queryBatchSwapCall { + kind: 0, // GIVEN_IN + swaps: vec![BatchSwapStep { + poolId: pool_id, + assetInIndex: U256::ZERO, + assetOutIndex: U256::from(1), + amount: amount_in, + userData: Bytes::new(), + }], + assets: vec![token_in, token_out], + funds: FundManagement { + sender: Address::ZERO, + fromInternalBalance: false, + recipient: Address::ZERO, + toInternalBalance: false, + }, + } + .abi_encode(), + ); + + let output = run_quote(cache, vault, calldata)?; + let asset_deltas = queryBatchSwapCall::abi_decode_returns_validate(&output) + .map_err(|_| SimError::MalformedOutput("queryBatchSwap return"))?; + + // assetDeltas[1] is the tokenOut delta: negative = paid out by the vault. + let delta_out = asset_deltas + .get(1) + .copied() + .ok_or(SimError::MalformedOutput("missing tokenOut delta"))?; + if delta_out.is_positive() { + return Err(SimError::MalformedOutput( + "tokenOut delta is non-negative (no output)", + )); + } + let amount_out = U256::from(delta_out.unsigned_abs()); + + Ok(SwapQuote::new(amount_out)) + } +} + +/// The phase a [`BalancerV2ColdStartPlanner`] is in between rounds. +enum BalancerPhase { + /// Round 1 ran the `getPoolTokens` discover call; classify its result next. + Discover, + /// Round 2 verified the discovered balance slots; the next `on_results` is done. + Verify, +} + +/// Why a Balancer cold start could not reach `Ready`. +enum BalancerRepair { + /// The discover call reverted, halted, or returned undecodable data. + DiscoverFailed, + /// The discover call decoded but touched no slots under `restrict_to`. + NoSlotsDiscovered, + /// A discovered vault balance slot could not be fetched in the verify round + /// (an archive miss), so the warmed balances are not authoritative. + BalancesUnfetched, +} + +/// Cold-start planner for a Balancer V2 pool: a discover → verify access-list run. +/// +/// Balancer pool state lives in the vault behind a non-predictable storage layout, +/// so the planner cannot name the balance slots up front. Instead round 1 runs a +/// `getPoolTokens(poolId)` view-call on the vault (`restrict_to = [vault]`) and +/// captures the `(vault, slot)` pairs it SLOADs. Round 2 authoritatively verifies +/// exactly those discovered slots so the live balances are warmed. The token list +/// is decoded from the discover call's return data. +/// +/// The flow runs for every policy: the vault balances are the hot state, so there +/// is no verify-only shortcut. The planner stays policy-aware in shape (the policy +/// is threaded into the report) so later slices can refine `HotSlotsOnly`/`Lazy`. +struct BalancerV2ColdStartPlanner { + vault: Address, + pool_id: B256, + policy: ColdStartPolicy, + phase: BalancerPhase, + /// Tokens decoded from the `getPoolTokens` return data (round 1). + tokens: Vec
, + /// The vault balance slots discovered in round 1 and verified in round 2. + verified_slots: Vec<(Address, U256)>, + /// Slots injected across the run (the refreshed balances). + changed_slots: Vec, + /// Set when the run cannot reach `Ready` (discover failure / empty capture). + repair: Option, +} + +impl BalancerV2ColdStartPlanner { + fn new(vault: Address, pool_id: B256, policy: ColdStartPolicy) -> Self { + Self { + vault, + pool_id, + policy, + phase: BalancerPhase::Discover, + tokens: Vec::new(), + verified_slots: Vec::new(), + changed_slots: Vec::new(), + repair: None, + } + } +} + +impl AdapterColdStartPlanner for BalancerV2ColdStartPlanner { + fn initial_plan(&mut self, _state: &dyn StateView) -> ColdStartPlan { + // Round 1: ensure the vault's code, then run `getPoolTokens` and capture + // the vault slots it touches (restricted to the vault so only its balance + // storage is collected). + ColdStartPlan { + accounts: vec![self.vault], + discover: vec![ColdStartCall { + from: Address::ZERO, + to: self.vault, + calldata: Bytes::from( + getPoolTokensCall { + poolId: self.pool_id, + } + .abi_encode(), + ), + restrict_to: Some(vec![self.vault]), + }], + ..Default::default() + } + } + + fn on_results(&mut self, results: &ColdStartResults, _state: &dyn StateView) -> ColdStartStep { + // Record any slots injected this round (round 2's refreshed balances). + self.changed_slots.extend(results.verified.iter().cloned()); + + match self.phase { + BalancerPhase::Discover => { + let Some(call) = results.discovered.first() else { + // No discover result at all — treat as a failed discovery. + self.repair = Some(BalancerRepair::DiscoverFailed); + return ColdStartStep::Done; + }; + + // Classify off the load-bearing success signal first (mirroring + // the V2/V3 planners) rather than relying on the decoder to + // reject a revert/halt payload. + if !call.result.is_success() { + self.repair = Some(BalancerRepair::DiscoverFailed); + return ColdStartStep::Done; + } + // Decode the token list from the call's return data. Undecodable + // data is a degraded/unsupported pool, not a panic. Use the + // validating decoder so a malformed payload is rejected, not + // best-effort reinterpreted. + let Some(output) = call.result.output() else { + self.repair = Some(BalancerRepair::DiscoverFailed); + return ColdStartStep::Done; + }; + match getPoolTokensCall::abi_decode_returns_validate(output) { + Ok(decoded) => self.tokens = decoded.tokens, + Err(_) => { + self.repair = Some(BalancerRepair::DiscoverFailed); + return ColdStartStep::Done; + } + } + + // Collect the discovered vault slots (already restricted to the + // vault). The access list is a set, so order is unspecified. + let discovered: Vec<(Address, U256)> = call + .access + .slots + .iter() + .filter(|(address, _)| *address == self.vault) + .copied() + .collect(); + + // Empty capture is a distinguishable signal: a verify round over + // zero slots would be a no-op, so record a repair and finish rather + // than continue. + if discovered.is_empty() { + self.repair = Some(BalancerRepair::NoSlotsDiscovered); + return ColdStartStep::Done; + } + + self.verified_slots = discovered.clone(); + self.phase = BalancerPhase::Verify; + ColdStartStep::Continue(ColdStartPlan { + verify: discovered, + ..Default::default() + }) + } + BalancerPhase::Verify => { + // The discovered vault slots are the hot state. Source their + // verdict from the per-slot `SlotFetch` classification (like the + // V2/V3 planners) so an archive miss is not silently accepted as + // a warmed `Ready`. A genuine `Zero` is legitimate (a fresh pool + // can hold a zero balance), so only an unfetchable / never- + // attempted slot forces a repair. + let any_unfetched = self.verified_slots.iter().any(|(address, slot)| { + matches!( + results + .fetched + .iter() + .find(|o| o.address == *address && o.slot == *slot) + .map(|o| &o.fetch), + Some(SlotFetch::FetchFailed { .. }) | Some(SlotFetch::NotAttempted) | None + ) + }); + if any_unfetched { + self.repair = Some(BalancerRepair::BalancesUnfetched); + } + ColdStartStep::Done + } + } + } + + fn finish( + &mut self, + pool: &mut PoolRegistration, + _report: &ColdStartRunReport, + ) -> ColdStartOutcome { + let mut report = ColdStartReport::new(pool.key.clone(), self.policy); + report.verified_slots = self.verified_slots.clone(); + report.changed_slots = self.changed_slots.clone(); + + match self.repair { + Some(BalancerRepair::DiscoverFailed) => { + report.status = PoolStatus::Degraded; + // Re-running discovery from scratch is the repair for a failed or + // undecodable `getPoolTokens` call. + ColdStartOutcome::NeedsRepair( + report, + RepairAction::ColdStart { + pool: pool.key.clone(), + policy: self.policy, + }, + ) + } + Some(BalancerRepair::NoSlotsDiscovered) => { + report.status = PoolStatus::Degraded; + // The vault is a shared singleton, so a wholesale + // PurgeStorage(vault) would wipe every co-tenant Balancer pool's + // warmed state. Nothing pool-specific was discovered to scope a + // purge to, so re-run discovery instead (as DiscoverFailed does). + ColdStartOutcome::NeedsRepair( + report, + RepairAction::ColdStart { + pool: pool.key.clone(), + policy: self.policy, + }, + ) + } + Some(BalancerRepair::BalancesUnfetched) => { + report.status = PoolStatus::Degraded; + // Archive-miss repair: re-verify exactly the discovered slots + // (mirrors the V2/V3 archive-miss repair). + ColdStartOutcome::NeedsRepair( + report, + RepairAction::VerifySlots(self.verified_slots.clone()), + ) + } + None => { + // The pool address is the leading 20 bytes of the poolId, matching + // Balancer's poolId encoding (`address(20) | nonce/kind`). + let pool_address = Address::from_slice(&self.pool_id.as_slice()[..20]); + // Persist the discovered balance slots (slot-only; all on the + // vault) so the reactive `Swap` path can refresh exactly them. + // The discovered set is order-unspecified; sort for a stable, + // deduped record. + let mut balance_slots: Vec = + self.verified_slots.iter().map(|(_, slot)| *slot).collect(); + balance_slots.sort_unstable(); + balance_slots.dedup(); + pool.metadata = ProtocolMetadata::BalancerV2(BalancerV2Metadata { + vault: Some(self.vault), + pool_address: Some(pool_address), + tokens: self.tokens.clone(), + balance_slots, + }); + pool.status = PoolStatus::Ready; + report.status = PoolStatus::Ready; + ColdStartOutcome::Ready(report) + } + } + } } diff --git a/src/adapters/mod.rs b/src/adapters/mod.rs index 5d6e72d..ce274d5 100644 --- a/src/adapters/mod.rs +++ b/src/adapters/mod.rs @@ -10,6 +10,7 @@ pub mod driver; pub mod reactive; pub mod registry; pub mod repair; +pub mod sim; pub mod storage; pub mod traits; pub mod types; @@ -17,6 +18,8 @@ pub mod types; // Per-protocol adapters — gated by their protocol feature. #[cfg(feature = "balancer-v2")] pub mod balancer_v2; +#[cfg(feature = "solidly-v2")] +pub mod solidly_v2; #[cfg(feature = "uniswap-v2")] pub mod uniswap_v2; #[cfg(feature = "uniswap-v3")] @@ -30,17 +33,20 @@ pub use cold_start::AdapterColdStartPlanner; pub use driver::AdapterDriver; pub use reactive::AmmReactiveHandler; pub use registry::{AdapterRegistry, RegistryError, SubscriptionSpec}; +pub use sim::{SimConfig, SimError, SwapQuote}; pub use traits::AmmAdapter; pub use types::{ AdapterEvent, AdapterEventError, AdapterEventKind, AdapterEventReport, AdapterEventResult, BalancerV2Metadata, ColdStartOutcome, ColdStartPolicy, ColdStartReport, CustomPoolKey, - DeferredWork, EventRoute, EventSource, PoolKey, PoolRegistration, PoolStatus, ProtocolId, - ProtocolMetadata, RepairAction, UniswapV2Metadata, UnsupportedReason, UpdateQuality, - V3Metadata, + DeferredOutcome, DeferredWork, EventRoute, EventSource, PoolKey, PoolRegistration, PoolStatus, + ProtocolId, ProtocolMetadata, RepairAction, SolidlyV2Metadata, UniswapV2Metadata, + UnsupportedReason, UpdateQuality, V3Metadata, }; #[cfg(feature = "balancer-v2")] pub use balancer_v2::BalancerV2Adapter; +#[cfg(feature = "solidly-v2")] +pub use solidly_v2::SolidlyV2Adapter; #[cfg(feature = "uniswap-v2")] pub use uniswap_v2::UniswapV2Adapter; #[cfg(feature = "uniswap-v3")] diff --git a/src/adapters/registry.rs b/src/adapters/registry.rs index 70134f2..e942255 100644 --- a/src/adapters/registry.rs +++ b/src/adapters/registry.rs @@ -4,7 +4,10 @@ use std::sync::Arc; use alloy_primitives::{Address, B256, Log}; -use super::{AmmAdapter, EventRoute, EventSource, PoolKey, PoolRegistration, ProtocolId}; +use super::{ + AdapterCache, AmmAdapter, DeferredOutcome, DeferredWork, EventRoute, EventSource, PoolKey, + PoolRegistration, ProtocolId, RepairAction, +}; /// Registry of tracked AMM pools and protocol adapters. #[derive(Clone, Default)] @@ -124,6 +127,52 @@ impl AdapterRegistry { pub fn is_empty(&self) -> bool { self.pools.is_empty() } + + /// Execute the [`DeferredWork`] produced by a `Lazy` + /// [`cold_start`](Self::cold_start) (or any other source) against `cache`. + /// + /// `cold_start` returns + /// [`ColdStartOutcome::ReadyWithDeferred`](super::ColdStartOutcome::ReadyWithDeferred) + /// for the `Lazy` policy but deliberately leaves the deferred slots unwarmed; + /// this driver is the explicit, consumer-invoked step that warms them when the + /// consumer is ready. + /// + /// Handling per variant: + /// - [`DeferredWork::VerifySlots`] and + /// [`DeferredWork::Repair`]`(`[`RepairAction::VerifySlots`]`)` → + /// [`AdapterCache::verify_slots`]; the returned [`SlotChange`](super::SlotChange)s + /// accumulate into [`DeferredOutcome::verified`]. + /// - [`DeferredWork::ColdStart`], [`DeferredWork::Custom`], and any other + /// [`DeferredWork::Repair`] variant are *not* executed here (they need + /// repair execution / re-cold-start-by-key, out of scope for this driver); + /// they are pushed verbatim into [`DeferredOutcome::unhandled`] rather than + /// dropped or panicked on. + /// + /// Takes `&self`: warming `VerifySlots` mutates only the `cache`, not the + /// registry. Errors from `verify_slots` propagate via the returned `Result`. + pub fn run_deferred( + &self, + deferred: &[DeferredWork], + cache: &mut dyn AdapterCache, + ) -> anyhow::Result { + let mut outcome = DeferredOutcome::default(); + + for work in deferred { + match work { + DeferredWork::VerifySlots(slots) + | DeferredWork::Repair(RepairAction::VerifySlots(slots)) => { + outcome.verified.extend(cache.verify_slots(slots)?); + } + DeferredWork::Repair(_) + | DeferredWork::ColdStart { .. } + | DeferredWork::Custom(_) => { + outcome.unhandled.push(work.clone()); + } + } + } + + Ok(outcome) + } } impl fmt::Debug for AdapterRegistry { diff --git a/src/adapters/sim.rs b/src/adapters/sim.rs new file mode 100644 index 0000000..358bf01 --- /dev/null +++ b/src/adapters/sim.rs @@ -0,0 +1,238 @@ +//! Swap simulation surface and quote-target configuration. +//! +//! [`AmmAdapter::simulate_swap`](super::AmmAdapter::simulate_swap) executes the +//! protocol's *canonical* on-chain quote entrypoint against the cold-start +//! snapshot via `AdapterCache::call_raw` and decodes the resulting `amount_out` +//! into a [`SwapQuote`] (or a [`SimError`] on revert). The deployed contract +//! bytecode performs the AMM math — this crate only builds calldata, runs it, +//! and decodes the output (no `amm-math` / `LocalAMM` / hand-rolled AMM math). +//! +//! - **Uniswap V3 (+ family):** `QuoterV2.quoteExactInputSingle(..)`. Target = +//! the QuoterV2 contract (mainnet default, per-pool/chain override). +//! - **Uniswap V2:** `UniswapV2Router02.getAmountsOut(amountIn, path)`. Target = +//! the router (mainnet default, override). +//! - **Balancer V2:** `Vault.queryBatchSwap(GIVEN_IN, swaps, assets, funds)`. +//! Target = the pool's vault (from `BalancerV2Metadata.vault`). +//! +//! The quote contract's bytecode must be reachable: lazily fetched against a +//! live backend, or installed as a fixture for offline tests. + +#[cfg(any( + feature = "uniswap-v2", + feature = "uniswap-v3", + feature = "balancer-v2", + feature = "solidly-v2" +))] +use alloy_primitives::Bytes; +use alloy_primitives::{Address, U256, address}; +use alloy_sol_types::sol; +#[cfg(any( + feature = "uniswap-v2", + feature = "uniswap-v3", + feature = "balancer-v2", + feature = "solidly-v2" +))] +use revm::context::result::ExecutionResult; + +#[cfg(any( + feature = "uniswap-v2", + feature = "uniswap-v3", + feature = "balancer-v2", + feature = "solidly-v2" +))] +use super::AdapterCache; + +/// A swap-simulation quote: the output amount the protocol's canonical quote +/// entrypoint returns for the requested input. +/// +/// Intentionally a struct (not a bare `U256`) so future quote outputs (gas, +/// effective price, sqrt-price-after) can extend it without breaking callers. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SwapQuote { + /// The token-out amount the quote returned for `amount_in`. + pub amount_out: U256, +} + +impl SwapQuote { + /// Construct a quote from a decoded output amount. + pub fn new(amount_out: U256) -> Self { + Self { amount_out } + } +} + +/// Why a [`simulate_swap`](super::AmmAdapter::simulate_swap) could not produce a +/// quote. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SimError { + /// The adapter does not implement swap simulation for its protocol. + Unsupported(super::ProtocolId), + /// Required metadata (e.g. the Balancer vault, or a V3 fee) is missing. + MissingMetadata(&'static str), + /// The quote call reverted or halted in the EVM. + Reverted, + /// The quote call executed but its return data could not be decoded. + MalformedOutput(&'static str), + /// The underlying `call_raw` failed (host/transact error). + Execution(String), + /// A catch-all for protocol-specific failures. + Custom(String), +} + +impl core::fmt::Display for SimError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::Unsupported(protocol) => { + write!(f, "swap simulation unsupported for {protocol:?}") + } + Self::MissingMetadata(what) => write!(f, "missing metadata for swap sim: {what}"), + Self::Reverted => write!(f, "quote call reverted or halted"), + Self::MalformedOutput(what) => write!(f, "malformed quote output: {what}"), + Self::Execution(err) => write!(f, "quote execution failed: {err}"), + Self::Custom(err) => write!(f, "swap sim error: {err}"), + } + } +} + +impl std::error::Error for SimError {} + +/// Resolved quote-target addresses for swap simulation. +/// +/// Defaults to the canonical Ethereum-mainnet QuoterV2 and UniswapV2Router02. +/// Per-pool/chain overrides are applied with the `with_*` builders. The Balancer +/// vault is *not* configured here — it is the pool's own vault +/// (`BalancerV2Metadata.vault`), resolved per-pool at quote time. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SimConfig { + /// Uniswap V3 `QuoterV2` (and family) quote target. + pub v3_quoter: Address, + /// Uniswap V2 `UniswapV2Router02` quote target. + pub v2_router: Address, +} + +/// Ethereum-mainnet Uniswap V3 `QuoterV2`. +pub const MAINNET_V3_QUOTER_V2: Address = address!("61fFE014bA17989E743c5F6cB21bF9697530B21e"); + +/// Ethereum-mainnet `UniswapV2Router02`. +pub const MAINNET_V2_ROUTER_02: Address = address!("7a250d5630B4cF539739dF2C5dAcb4c659F2488D"); + +impl Default for SimConfig { + fn default() -> Self { + Self { + v3_quoter: MAINNET_V3_QUOTER_V2, + v2_router: MAINNET_V2_ROUTER_02, + } + } +} + +impl SimConfig { + /// Override the Uniswap V3 QuoterV2 target (e.g. a non-mainnet chain or a + /// per-deployment quoter). + pub fn with_v3_quoter(mut self, quoter: Address) -> Self { + self.v3_quoter = quoter; + self + } + + /// Override the Uniswap V2 Router02 target. + pub fn with_v2_router(mut self, router: Address) -> Self { + self.v2_router = router; + self + } +} + +/// Run a quote `calldata` against `target` on the cache and return the raw +/// success output, mapping revert/halt to [`SimError::Reverted`]. +/// +/// The call is executed with `from = ZERO`, `commit = false` — it never mutates +/// the cache, only reads the warmed snapshot (lazily fetching cold slots from +/// the backend when one is configured). Used by every per-protocol +/// `simulate_swap` so the execution + revert classification lives in one place; +/// the protocol-specific code only builds calldata and decodes the output. +/// +/// Gated on the protocols that call it: with no protocol adapter compiled there +/// is no `simulate_swap` impl, so this helper would be dead code. +#[cfg(any( + feature = "uniswap-v2", + feature = "uniswap-v3", + feature = "balancer-v2", + feature = "solidly-v2" +))] +pub(crate) fn run_quote( + cache: &mut dyn AdapterCache, + target: Address, + calldata: Bytes, +) -> Result { + let result = cache + .call_raw(Address::ZERO, target, calldata, false) + .map_err(|err| SimError::Execution(err.to_string()))?; + + match result { + ExecutionResult::Success { output, .. } => Ok(output.into_data()), + ExecutionResult::Revert { .. } | ExecutionResult::Halt { .. } => Err(SimError::Reverted), + } +} + +sol! { + /// Uniswap V3 `QuoterV2.quoteExactInputSingle` (the struct-arg variant). + /// + /// `sqrtPriceLimitX96 = 0` means "no limit" (quote the full input). Returns + /// `amountOut` plus auxiliary fields we ignore. + struct QuoteExactInputSingleParams { + address tokenIn; + address tokenOut; + uint256 amountIn; + uint24 fee; + uint160 sqrtPriceLimitX96; + } + + function quoteExactInputSingle(QuoteExactInputSingleParams params) + returns ( + uint256 amountOut, + uint160 sqrtPriceX96After, + uint32 initializedTicksCrossed, + uint256 gasEstimate + ); + + /// Uniswap V2 `UniswapV2Router02.getAmountsOut(amountIn, path)`. + /// + /// Runs the on-chain `UniswapV2Library` math against the warmed pair + /// reserves and returns the amount at each hop; the last element is the + /// output for the final token in `path`. + function getAmountsOut(uint256 amountIn, address[] path) + returns (uint256[] amounts); + + /// Solidly V2 (Velodrome / Aerodrome) `Pool.getAmountOut(amountIn, tokenIn)`. + /// + /// Subtracts the fee via an external `IPoolFactory(factory).getFee()` + /// STATICCALL, then applies the stable (x³y+y³x) or volatile (xy=k) invariant + /// in-EVM and returns the `tokenOut` amount. Beyond the reserves it reads + /// `factory`/`stable`/`token0`/`decimals0`/`decimals1` from pool storage, so + /// the factory's bytecode + those slots must be reachable (not just reserves). + function getAmountOut(uint256 amountIn, address tokenIn) returns (uint256 amountOut); + + /// Balancer V2 `Vault.queryBatchSwap(kind, swaps, assets, funds)`. + /// + /// `kind = 0` is `GIVEN_IN`. Returns the signed asset deltas (per `assets` + /// index): positive = owed to the vault (input), negative = paid out by the + /// vault (output). + function queryBatchSwap( + uint8 kind, + BatchSwapStep[] swaps, + address[] assets, + FundManagement funds + ) returns (int256[] assetDeltas); + + struct BatchSwapStep { + bytes32 poolId; + uint256 assetInIndex; + uint256 assetOutIndex; + uint256 amount; + bytes userData; + } + + struct FundManagement { + address sender; + bool fromInternalBalance; + address recipient; + bool toInternalBalance; + } +} diff --git a/src/adapters/solidly_v2.rs b/src/adapters/solidly_v2.rs new file mode 100644 index 0000000..b71f400 --- /dev/null +++ b/src/adapters/solidly_v2.rs @@ -0,0 +1,381 @@ +use alloy_primitives::{Address, Bytes, Log, U256}; +use alloy_sol_types::{SolCall, SolEvent, sol}; +use evm_fork_cache::cold_start::{ + ColdStartPlan, ColdStartResults, ColdStartRunReport, ColdStartStep, SlotFetch, +}; + +use super::cold_start::AdapterColdStartPlanner; +use super::sim::{SimConfig, SimError, SwapQuote, getAmountOutCall, run_quote}; +use super::storage::{SolidlyStorageLayout, decode_address_slot}; +use super::{ + AdapterCache, AdapterEvent, AdapterEventError, AdapterEventKind, AdapterEventResult, + AmmAdapter, ColdStartOutcome, ColdStartPolicy, ColdStartReport, DeferredWork, EventSource, + PoolRegistration, PoolStatus, ProtocolId, ProtocolMetadata, RepairAction, SlotChange, + SolidlyV2Metadata, StateUpdate, StateView, UnsupportedReason, UpdateQuality, +}; + +sol! { + // Velodrome V2 / Aerodrome pools emit reserves as two separate uint256 values + // (unlike Uniswap V2's packed uint112,uint112). + event Sync(uint256 reserve0, uint256 reserve1); +} + +/// Adapter for Solidly V2 (Aerodrome / Velodrome V2) reserves pools. +/// +/// Mirrors the Uniswap V2 adapter (reserves + `Sync`), but reserves live in TWO +/// separate `uint256` storage slots (not V2's single packed slot), so reactive +/// writes are two plain slot writes. Swap simulation calls the pool's own +/// `getAmountOut(amountIn, tokenIn)`, which applies the stable (x³y+y³x) or +/// volatile (xy=k) invariant in-EVM — no math is reimplemented here. +/// +/// The storage layout is config-supplied via [`SolidlyV2Metadata::storage_layout`] +/// ([`SolidlyStorageLayout`]); slot indices are fork-specific, so there is no +/// derivable default (cold-start returns [`UnsupportedReason::MissingMetadata`] +/// without one). Validate a fork's layout with the gated RPC-parity test. +#[derive(Clone, Debug, Default)] +pub struct SolidlyV2Adapter { + _private: (), +} + +fn solidly_layout(pool: &PoolRegistration) -> Option { + match &pool.metadata { + ProtocolMetadata::SolidlyV2(metadata) => metadata.storage_layout, + _ => None, + } +} + +impl AmmAdapter for SolidlyV2Adapter { + fn protocol(&self) -> ProtocolId { + ProtocolId::SolidlyV2 + } + + fn event_sources(&self, pool: &PoolRegistration) -> Vec { + pool.key + .address() + .map(|address| EventSource::direct(address, vec![Sync::SIGNATURE_HASH])) + .into_iter() + .collect() + } + + fn cold_start_planner( + &self, + pool: &PoolRegistration, + policy: ColdStartPolicy, + ) -> Result, UnsupportedReason> { + let Some(address) = pool.key.address() else { + return Err(UnsupportedReason::Custom( + "Solidly V2 pool key is not address-keyed".into(), + )); + }; + let Some(layout) = solidly_layout(pool) else { + return Err(UnsupportedReason::MissingMetadata( + "Solidly V2 storage layout", + )); + }; + // reserve0/reserve1/token0/token1 are four distinct storage variables on + // a real pool; a colliding layout would silently corrupt the cold-start + // verdict and token decode (and clobber one reserve write), so reject it. + let slots = [ + layout.reserve0_slot, + layout.reserve1_slot, + layout.token0_slot, + layout.token1_slot, + ]; + for i in 0..slots.len() { + for j in (i + 1)..slots.len() { + if slots[i] == slots[j] { + return Err(UnsupportedReason::Custom( + "Solidly V2 storage layout slots must be pairwise distinct".into(), + )); + } + } + } + Ok(Box::new(SolidlyV2ColdStartPlanner::new( + address, layout, policy, + ))) + } + + fn decode_event( + &self, + pool: &PoolRegistration, + log: &Log, + _view: &dyn StateView, + ) -> AdapterEventResult { + if log.topics().first() != Some(&Sync::SIGNATURE_HASH) { + return AdapterEventResult::ignored(); + } + + if Sync::decode_log_data_validate(&log.data).is_err() { + return AdapterEventResult::error(AdapterEventError::MalformedLog( + "malformed Solidly V2 Sync log", + )); + } + + let Some(address) = pool.key.address() else { + return AdapterEventResult::error(AdapterEventError::MalformedLog( + "Solidly V2 pool key is not address-keyed", + )); + }; + let Some(layout) = solidly_layout(pool) else { + // A missing layout is a config issue (the pool was not cold-started), + // not a malformed log. Skip the event rather than returning an error + // that would fail the whole reactive batch — and without a layout + // there are no slots to target anyway. + return AdapterEventResult::ignored(); + }; + + let Some(reserve0) = data_word(log, 0) else { + return AdapterEventResult::error(AdapterEventError::MalformedLog( + "missing Solidly V2 reserve0", + )); + }; + let Some(reserve1) = data_word(log, 1) else { + return AdapterEventResult::error(AdapterEventError::MalformedLog( + "missing Solidly V2 reserve1", + )); + }; + + // Two exact full-slot writes from the event payload (no fetch) — Solidly + // stores the reserves unpacked, one uint256 per slot. + AdapterEventResult::event(AdapterEvent { + pool: pool.key.clone(), + emitter: log.address, + topic0: Sync::SIGNATURE_HASH, + kind: AdapterEventKind::Sync, + updates: vec![ + StateUpdate::slot(address, layout.reserve0_slot, reserve0), + StateUpdate::slot(address, layout.reserve1_slot, reserve1), + ], + quality: UpdateQuality::ExactIfApplied, + repair: RepairAction::None, + }) + } + + // No `after_apply` override (unlike V2): Solidly stores each reserve in its + // own full uint256 slot, so decode_event's absolute `StateUpdate::slot` + // writes are always exact — a full Slot write is never cold-skipped (unlike + // V2's masked write into a packed slot), so `StateDiff::has_skipped()` can + // never be true here and the default `RepairAction::None` is correct. No + // cold-slot resync is ever needed. + + /// Quote via the pool's own `getAmountOut(amountIn, tokenIn)` (chain code, no + /// reimplemented math). Beyond the warmed reserves the pool also reads its + /// `stable` flag + `token0`/`decimals0`/`decimals1` and STATICCALLs the + /// factory's `getFee`, so the quote is NOT reproducible from warmed reserves + /// alone — those slots and the factory's bytecode must be reachable (lazily + /// fetched from a live backend, or installed for offline tests). `token_out` + /// is implied (the pool's other token), so it is not part of the call. + fn simulate_swap( + &self, + pool: &PoolRegistration, + cache: &mut dyn AdapterCache, + token_in: Address, + _token_out: Address, + amount_in: U256, + _config: &SimConfig, + ) -> Result { + let pool_address = pool + .key + .address() + .ok_or(SimError::MissingMetadata("Solidly V2 pool address"))?; + + let calldata = Bytes::from( + getAmountOutCall { + amountIn: amount_in, + tokenIn: token_in, + } + .abi_encode(), + ); + + let output = run_quote(cache, pool_address, calldata)?; + let amount_out = getAmountOutCall::abi_decode_returns_validate(&output) + .map_err(|_| SimError::MalformedOutput("getAmountOut return"))?; + Ok(SwapQuote::new(amount_out)) + } +} + +/// The classified verdict of the mandatory reserve slots. +#[derive(Clone, Copy)] +enum SolidlyVerdict { + /// Both reserves warmed; pool is ready. + Ready, + /// A reserve read a genuine on-chain zero (degenerate pool). + DegenerateZero, + /// A reserve could not be fetched (archive / historical miss). + FetchFailed, +} + +/// Cold-start planner for a Solidly V2 pool: a single verify-only round. +/// +/// Verifies `reserve0`/`reserve1` (always — both mandatory) plus the token slots +/// under `Strict`/`Eager`. The reserves are classified from their per-slot +/// [`SlotFetch`] verdict, so a genuine zero and an archive miss map to *distinct* +/// repairs. Token addresses are decoded from the warmed slots and merged into the +/// metadata; the config-supplied `stable`/`storage_layout` are preserved. Under +/// `Lazy` the token slots are recorded as deferred work. +struct SolidlyV2ColdStartPlanner { + address: Address, + layout: SolidlyStorageLayout, + policy: ColdStartPolicy, + verified_slots: Vec<(Address, U256)>, + changed_slots: Vec, + decoded_token0: Option
, + decoded_token1: Option
, + verdict: Option, +} + +impl SolidlyV2ColdStartPlanner { + fn new(address: Address, layout: SolidlyStorageLayout, policy: ColdStartPolicy) -> Self { + Self { + address, + layout, + policy, + verified_slots: Vec::new(), + changed_slots: Vec::new(), + decoded_token0: None, + decoded_token1: None, + verdict: None, + } + } +} + +/// Classify a single reserve slot from its per-slot [`SlotFetch`] outcome. +fn classify_slot(results: &ColdStartResults, address: Address, slot: U256) -> SolidlyVerdict { + match results + .fetched + .iter() + .find(|o| o.address == address && o.slot == slot) + .map(|o| &o.fetch) + { + Some(SlotFetch::Value(_)) => SolidlyVerdict::Ready, + Some(SlotFetch::Zero) => SolidlyVerdict::DegenerateZero, + Some(SlotFetch::FetchFailed { .. }) | Some(SlotFetch::NotAttempted) | None => { + SolidlyVerdict::FetchFailed + } + } +} + +impl AdapterColdStartPlanner for SolidlyV2ColdStartPlanner { + fn initial_plan(&mut self, _state: &dyn StateView) -> ColdStartPlan { + // Both reserve slots are always warmed so subsequent reactive `Sync` + // writes land exactly. The token slots are warmed up-front except under + // `Lazy`/`HotSlotsOnly`. + let verify: Vec<(Address, U256)> = match self.policy { + ColdStartPolicy::Strict | ColdStartPolicy::Eager => vec![ + (self.address, self.layout.reserve0_slot), + (self.address, self.layout.reserve1_slot), + (self.address, self.layout.token0_slot), + (self.address, self.layout.token1_slot), + ], + ColdStartPolicy::Lazy | ColdStartPolicy::HotSlotsOnly => vec![ + (self.address, self.layout.reserve0_slot), + (self.address, self.layout.reserve1_slot), + ], + }; + self.verified_slots = verify.clone(); + ColdStartPlan { + verify, + ..Default::default() + } + } + + fn on_results(&mut self, results: &ColdStartResults, state: &dyn StateView) -> ColdStartStep { + self.changed_slots.extend(results.verified.iter().cloned()); + + // Both reserves are mandatory: combine their per-slot verdicts — + // FetchFailed dominates (archive miss), then DegenerateZero. + let r0 = classify_slot(results, self.address, self.layout.reserve0_slot); + let r1 = classify_slot(results, self.address, self.layout.reserve1_slot); + self.verdict = Some(match (r0, r1) { + (SolidlyVerdict::FetchFailed, _) | (_, SolidlyVerdict::FetchFailed) => { + SolidlyVerdict::FetchFailed + } + (SolidlyVerdict::DegenerateZero, _) | (_, SolidlyVerdict::DegenerateZero) => { + SolidlyVerdict::DegenerateZero + } + (SolidlyVerdict::Ready, SolidlyVerdict::Ready) => SolidlyVerdict::Ready, + }); + + self.decoded_token0 = state + .storage(self.address, self.layout.token0_slot) + .map(decode_address_slot); + self.decoded_token1 = state + .storage(self.address, self.layout.token1_slot) + .map(decode_address_slot); + + ColdStartStep::Done + } + + fn finish( + &mut self, + pool: &mut PoolRegistration, + _report: &ColdStartRunReport, + ) -> ColdStartOutcome { + let mut cold_report = ColdStartReport::new(pool.key.clone(), self.policy); + cold_report.verified_slots = self.verified_slots.clone(); + cold_report.changed_slots = self.changed_slots.clone(); + + let reserve_slots = vec![ + (self.address, self.layout.reserve0_slot), + (self.address, self.layout.reserve1_slot), + ]; + + match self.verdict { + Some(SolidlyVerdict::DegenerateZero) => { + cold_report.status = PoolStatus::Degraded; + // Distinct from the archive-miss repair: a genuine zero is a + // degenerate pool, so purge the stale slots rather than re-verify. + ColdStartOutcome::NeedsRepair( + cold_report, + RepairAction::PurgeSlots { + address: self.address, + slots: vec![self.layout.reserve0_slot, self.layout.reserve1_slot], + }, + ) + } + Some(SolidlyVerdict::FetchFailed) | None => { + cold_report.status = PoolStatus::Degraded; + ColdStartOutcome::NeedsRepair(cold_report, RepairAction::VerifySlots(reserve_slots)) + } + Some(SolidlyVerdict::Ready) => { + // Merge decoded tokens; preserve config `stable`/`storage_layout`. + let metadata = match &pool.metadata { + ProtocolMetadata::SolidlyV2(existing) => SolidlyV2Metadata { + token0: self.decoded_token0, + token1: self.decoded_token1, + stable: existing.stable, + storage_layout: existing.storage_layout, + }, + _ => SolidlyV2Metadata { + token0: self.decoded_token0, + token1: self.decoded_token1, + stable: None, + storage_layout: Some(self.layout), + }, + }; + pool.metadata = ProtocolMetadata::SolidlyV2(metadata); + pool.status = PoolStatus::Ready; + cold_report.status = PoolStatus::Ready; + + if self.policy == ColdStartPolicy::Lazy { + let deferred = vec![DeferredWork::VerifySlots(vec![ + (self.address, self.layout.token0_slot), + (self.address, self.layout.token1_slot), + ])]; + cold_report.deferred = deferred.clone(); + ColdStartOutcome::ReadyWithDeferred(cold_report, deferred) + } else { + ColdStartOutcome::Ready(cold_report) + } + } + } + } +} + +fn data_word(log: &Log, index: usize) -> Option { + let start = index.checked_mul(32)?; + log.data + .data + .get(start..start + 32) + .map(U256::from_be_slice) +} diff --git a/src/adapters/storage.rs b/src/adapters/storage.rs index 0674061..0a98a9c 100644 --- a/src/adapters/storage.rs +++ b/src/adapters/storage.rs @@ -108,6 +108,37 @@ impl V3StorageLayout { } } +/// Storage layout for a Solidly V2 (Aerodrome / Velodrome V2) reserves pool. +/// +/// Reserves are two separate `uint256` slots (not packed like Uniswap V2). Slot +/// indices are fork-specific and config-supplied — there is no derivable default, +/// so validate a fork's layout with the gated RPC-parity test before relying on +/// it in production. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct SolidlyStorageLayout { + pub reserve0_slot: U256, + pub reserve1_slot: U256, + pub token0_slot: U256, + pub token1_slot: U256, +} + +impl SolidlyStorageLayout { + /// Build a Solidly V2 layout from explicit storage slots. + pub const fn new( + reserve0_slot: U256, + reserve1_slot: U256, + token0_slot: U256, + token1_slot: U256, + ) -> Self { + Self { + reserve0_slot, + reserve1_slot, + token0_slot, + token1_slot, + } + } +} + /// Decode an EVM address from a right-aligned 32-byte storage word (the low 20 /// bytes), as used for the `token0`/`token1` address slots of a Uniswap V2 pair. pub fn decode_address_slot(word: U256) -> Address { diff --git a/src/adapters/traits.rs b/src/adapters/traits.rs index 2564f42..a0d6971 100644 --- a/src/adapters/traits.rs +++ b/src/adapters/traits.rs @@ -1,9 +1,10 @@ -use alloy_primitives::Log; +use alloy_primitives::{Address, Log, U256}; use super::cold_start::AdapterColdStartPlanner; +use super::sim::{SimConfig, SimError, SwapQuote}; use super::{ - AdapterEvent, AdapterEventResult, AdapterRegistry, ColdStartPolicy, EventSource, PoolKey, - PoolRegistration, ProtocolId, RepairAction, StateDiff, StateView, UnsupportedReason, + AdapterCache, AdapterEvent, AdapterEventResult, AdapterRegistry, ColdStartPolicy, EventSource, + PoolKey, PoolRegistration, ProtocolId, RepairAction, StateDiff, StateView, UnsupportedReason, }; /// Protocol adapter contract for AMM-specific routing, cold-start, and decoding. @@ -81,4 +82,33 @@ pub trait AmmAdapter: Send + Sync { ) -> RepairAction { RepairAction::None } + + /// Simulate `amount_in` of `token_in` swapped to `token_out` for `pool`, + /// returning the protocol's canonical `amount_out`. + /// + /// The implementation builds the protocol's canonical *quote* calldata and + /// runs it via [`AdapterCache::call_raw`] with `from = ZERO`, + /// `to = `, `commit = false` against the cold-start snapshot, + /// then decodes `amount_out` from the [`ExecutionResult`] output. The + /// deployed contract bytecode does the AMM math — there is no `amm-math` / + /// `LocalAMM` / hand-rolled math here. A revert/halt maps to + /// [`SimError::Reverted`]. + /// + /// Quote targets are resolved from `config` (Uniswap V3 `QuoterV2`, Uniswap + /// V2 `Router02`) with the Balancer vault taken from the pool's metadata. + /// + /// [`ExecutionResult`]: revm::context::result::ExecutionResult + /// + /// Defaults to [`SimError::Unsupported`] for protocols without a quote impl. + fn simulate_swap( + &self, + _pool: &PoolRegistration, + _cache: &mut dyn AdapterCache, + _token_in: Address, + _token_out: Address, + _amount_in: U256, + _config: &SimConfig, + ) -> Result { + Err(SimError::Unsupported(self.protocol())) + } } diff --git a/src/adapters/types.rs b/src/adapters/types.rs index 1bdf7b1..7f243e7 100644 --- a/src/adapters/types.rs +++ b/src/adapters/types.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use alloy_primitives::{Address, B256, U256}; use super::cache::{SlotChange, StateDiff, StateUpdate}; -use super::storage::V3StorageLayout; +use super::storage::{SolidlyStorageLayout, V3StorageLayout}; /// Protocol family identifier for adapter registrations. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] @@ -249,6 +249,7 @@ pub enum ProtocolMetadata { PancakeV3(V3Metadata), Slipstream(V3Metadata), BalancerV2(BalancerV2Metadata), + SolidlyV2(SolidlyV2Metadata), Custom(Arc), } @@ -261,6 +262,7 @@ impl fmt::Debug for ProtocolMetadata { Self::PancakeV3(metadata) => f.debug_tuple("PancakeV3").field(metadata).finish(), Self::Slipstream(metadata) => f.debug_tuple("Slipstream").field(metadata).finish(), Self::BalancerV2(metadata) => f.debug_tuple("BalancerV2").field(metadata).finish(), + Self::SolidlyV2(metadata) => f.debug_tuple("SolidlyV2").field(metadata).finish(), Self::Custom(_) => f.write_str("Custom(..)"), } } @@ -282,11 +284,31 @@ pub struct V3Metadata { pub storage_layout: Option, } +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct SolidlyV2Metadata { + pub token0: Option
, + pub token1: Option
, + /// `true` for stable (x³y+y³x) pools, `false` for volatile (xy=k). Config- + /// supplied; preserved across cold-start. + pub stable: Option, + pub storage_layout: Option, +} + #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct BalancerV2Metadata { pub vault: Option
, pub pool_address: Option
, pub tokens: Vec
, + /// Vault balance storage slots discovered during cold-start (the `(vault, + /// slot)` pairs the `getPoolTokens` view-call SLOADed; recorded slot-only + /// since they all live on `vault`). + /// + /// Persisting them here lets the reactive `Swap` path refresh (re-verify) + /// exactly these slots — keeping the cached vault balances fresh for a + /// subsequent `simulate_swap` — without reverse-engineering the vault's + /// balance-mapping layout or doing lossy event-delta arithmetic. Empty + /// until the discover→verify cold-start runs. + pub balance_slots: Vec, } /// Lifecycle status for a tracked pool registration. @@ -507,6 +529,27 @@ pub enum DeferredWork { Custom(String), } +/// Result of running deferred cold-start work via +/// [`AdapterRegistry::run_deferred`](super::AdapterRegistry::run_deferred). +/// +/// `verified` accumulates the [`SlotChange`]s produced by warming +/// [`DeferredWork::VerifySlots`] (and `Repair(VerifySlots)`) entries. +/// `unhandled` collects, verbatim, any deferred work the driver does not execute +/// in this item (`ColdStart`, `Custom`, and non-`VerifySlots` repairs) so callers +/// can route them onward rather than have them silently dropped. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct DeferredOutcome { + pub verified: Vec, + pub unhandled: Vec, +} + +impl DeferredOutcome { + /// Whether every deferred item was executed (nothing was deferred onward). + pub fn is_fully_handled(&self) -> bool { + self.unhandled.is_empty() + } +} + /// Why a protocol state, event, or policy is not supported by the current adapter. #[derive(Clone, Debug, PartialEq, Eq)] pub enum UnsupportedReason { diff --git a/src/adapters/uniswap_v2.rs b/src/adapters/uniswap_v2.rs index c344430..6f538d7 100644 --- a/src/adapters/uniswap_v2.rs +++ b/src/adapters/uniswap_v2.rs @@ -1,14 +1,15 @@ -use alloy_primitives::{Address, Log, U256}; -use alloy_sol_types::{SolEvent, sol}; +use alloy_primitives::{Address, Bytes, Log, U256}; +use alloy_sol_types::{SolCall, SolEvent, sol}; use evm_fork_cache::cold_start::{ ColdStartPlan, ColdStartResults, ColdStartRunReport, ColdStartStep, SlotFetch, }; use super::cold_start::AdapterColdStartPlanner; +use super::sim::{SimConfig, SimError, SwapQuote, getAmountsOutCall, run_quote}; use super::storage::{V2_RESERVES_SLOT, V2_TOKEN0_SLOT, V2_TOKEN1_SLOT, decode_address_slot}; use super::{ - AdapterEvent, AdapterEventError, AdapterEventKind, AdapterEventResult, AmmAdapter, - ColdStartOutcome, ColdStartPolicy, ColdStartReport, DeferredWork, EventSource, + AdapterCache, AdapterEvent, AdapterEventError, AdapterEventKind, AdapterEventResult, + AmmAdapter, ColdStartOutcome, ColdStartPolicy, ColdStartReport, DeferredWork, EventSource, PoolRegistration, PoolStatus, ProtocolId, ProtocolMetadata, RepairAction, SlotChange, StateDiff, StateUpdate, StateView, UniswapV2Metadata, UnsupportedReason, UpdateQuality, }; @@ -115,6 +116,39 @@ impl AmmAdapter for UniswapV2Adapter { RepairAction::None } } + + /// Quote via `UniswapV2Router02.getAmountsOut(amountIn, [tokenIn, tokenOut])`. + /// + /// The router runs the on-chain `UniswapV2Library` reserves math against the + /// warmed pair reserves (chain code, not reimplemented math); the last + /// element of the returned `amounts` array is the output for `tokenOut`. + fn simulate_swap( + &self, + _pool: &PoolRegistration, + cache: &mut dyn AdapterCache, + token_in: Address, + token_out: Address, + amount_in: U256, + config: &SimConfig, + ) -> Result { + let calldata = Bytes::from( + getAmountsOutCall { + amountIn: amount_in, + path: vec![token_in, token_out], + } + .abi_encode(), + ); + + let output = run_quote(cache, config.v2_router, calldata)?; + let amounts = getAmountsOutCall::abi_decode_returns_validate(&output) + .map_err(|_| SimError::MalformedOutput("getAmountsOut return"))?; + + amounts + .last() + .copied() + .map(SwapQuote::new) + .ok_or(SimError::MalformedOutput("empty getAmountsOut amounts")) + } } /// Cold-start planner for a Uniswap V2 pair: a single verify-only round. diff --git a/src/adapters/uniswap_v3.rs b/src/adapters/uniswap_v3.rs index f72d2e6..514517f 100644 --- a/src/adapters/uniswap_v3.rs +++ b/src/adapters/uniswap_v3.rs @@ -1,15 +1,19 @@ -use alloy_primitives::{Address, B256, Log, U256}; -use alloy_sol_types::{SolEvent, sol}; +use alloy_primitives::{Address, B256, Bytes, Log, U256, aliases::U24}; +use alloy_sol_types::{SolCall, SolEvent, sol}; use evm_fork_cache::cold_start::{ ColdStartPlan, ColdStartResults, ColdStartRunReport, ColdStartStep, SlotFetch, }; use super::cold_start::AdapterColdStartPlanner; +use super::sim::{ + QuoteExactInputSingleParams, SimConfig, SimError, SwapQuote, quoteExactInputSingleCall, + run_quote, +}; use super::{ - AdapterEvent, AdapterEventError, AdapterEventKind, AdapterEventResult, AmmAdapter, - ColdStartOutcome, ColdStartPolicy, ColdStartReport, DeferredWork, EventSource, - PoolRegistration, PoolStatus, ProtocolId, RepairAction, SlotChange, StateDiff, StateUpdate, - StateView, UnsupportedReason, UpdateQuality, + AdapterCache, AdapterEvent, AdapterEventError, AdapterEventKind, AdapterEventResult, + AmmAdapter, ColdStartOutcome, ColdStartPolicy, ColdStartReport, DeferredWork, EventSource, + PoolRegistration, PoolStatus, ProtocolId, ProtocolMetadata, RepairAction, SlotChange, + StateDiff, StateUpdate, StateView, UnsupportedReason, UpdateQuality, V3Metadata, }; use crate::adapters::storage::{ V3StorageLayout, layout_for, v3_tick_bitmap_storage_key_with_base, @@ -25,6 +29,23 @@ sol! { const SLOT0_PRICE_TICK_BITS: usize = 184; const SLOT0_TICK_SHIFT: usize = 160; +/// The minimum/maximum tick a Uniswap V3 pool can reach (`±887272`). Ticks (and +/// the tick-bitmap words derived from them) outside this range never exist, so +/// the cold-start window is clamped to it to avoid warming non-existent slots. +const V3_MIN_TICK: i32 = -887272; +const V3_MAX_TICK: i32 = 887272; + +/// Radius (in tick-bitmap words) of the cold-start tick warm-up window. +/// +/// The warmed window is `[W0 - R, W0 + R]` — `2R + 1` words centred on the +/// current-tick word `W0`. One word covers `256 * tick_spacing` of tick range, +/// so `R = 2` pre-warms ±2 words: generous headroom for a moderate +/// tick-crossing swap while keeping the warm-up strictly bounded (never more +/// than `2R + 1` bitmap words plus their initialized ticks). A true +/// outward-adaptive scan (extend until N consecutive empty words) is a future +/// refinement; this single named constant is the tuning knob until then. +pub(crate) const V3_TICK_WORD_RADIUS: i16 = 2; + /// Adapter for the Uniswap V3 storage-layout family. /// /// A single instance serves Uniswap V3, Pancake V3, and Slipstream: those @@ -134,6 +155,53 @@ impl AmmAdapter for V3FamilyAdapter { RepairAction::VerifySlots(slots) } } + + /// Quote via `QuoterV2.quoteExactInputSingle((tokenIn, tokenOut, amountIn, + /// fee, sqrtPriceLimitX96 = 0))`. + /// + /// The Quoter executes a real V3 swap against the warmed pool slots and + /// returns the encoded `amountOut` (chain code, not reimplemented math). The + /// pool `fee` is taken from the V3-family metadata; tick-crossing swaps stay + /// correct because the cache lazily fetches any cold tick/bitmap slot from + /// the backend. + fn simulate_swap( + &self, + pool: &PoolRegistration, + cache: &mut dyn AdapterCache, + token_in: Address, + token_out: Address, + amount_in: U256, + config: &SimConfig, + ) -> Result { + let fee = v3_fee(pool).ok_or(SimError::MissingMetadata("V3 fee"))?; + + let params = QuoteExactInputSingleParams { + tokenIn: token_in, + tokenOut: token_out, + amountIn: amount_in, + fee: U24::from(fee), + sqrtPriceLimitX96: U256::ZERO.to(), + }; + let calldata = Bytes::from(quoteExactInputSingleCall { params }.abi_encode()); + + let output = run_quote(cache, config.v3_quoter, calldata)?; + let decoded = quoteExactInputSingleCall::abi_decode_returns_validate(&output) + .map_err(|_| SimError::MalformedOutput("quoteExactInputSingle return"))?; + + Ok(SwapQuote::new(decoded.amountOut)) + } +} + +/// Read the pool `fee` (in hundredths of a bip, e.g. `500` for 0.05%) from the +/// V3-family metadata, regardless of which family variant the pool registered. +fn v3_fee(pool: &PoolRegistration) -> Option { + let metadata: &V3Metadata = match &pool.metadata { + ProtocolMetadata::UniswapV3(m) + | ProtocolMetadata::PancakeV3(m) + | ProtocolMetadata::Slipstream(m) => m, + _ => return None, + }; + metadata.fee } impl V3FamilyAdapter { @@ -247,28 +315,31 @@ impl V3FamilyAdapter { /// Cold-start planner for the Uniswap V3 storage-layout family. /// -/// Re-expresses the A3 bounded current-tick warm-up as planner rounds: +/// Warms a bounded **window** of tick-bitmap words around the current tick as +/// planner rounds: /// /// - Round 1 verifies `slot0` + global `liquidity`. `slot0` is mandatory; its /// [`SlotFetch`] verdict decides ready vs. repair. From the warmed `slot0` the -/// current tick (and so the current `tickBitmap` word) is decoded. -/// - Round 2 (`Strict`/`Eager` only) verifies the current-tick bitmap word. +/// current tick — and so the current `tickBitmap` word `W0` — is decoded, then +/// the window `[W0 - R, W0 + R]` (`R = `[`V3_TICK_WORD_RADIUS`]) is computed, +/// clamped to the valid V3 word range, and each word's bitmap key resolved. +/// - Round 2 (`Strict`/`Eager` only) verifies **all** window bitmap words in one +/// round. /// - Round 3 (`Strict`/`Eager` only) verifies the `{0, 3}` info slots of every -/// tick initialized in that word. +/// tick initialized across the whole window in one round. /// -/// `HotSlotsOnly` stops after round 1 (slot0 + liquidity). `Lazy` stops after -/// round 1 and records the bitmap word as deferred work. The multi-word adaptive -/// scan stays deferred (future rounds, not this slice). Config-supplied V3 -/// metadata is preserved unchanged. +/// `HotSlotsOnly` stops after round 1 (slot0 + liquidity — no bitmap/tick +/// warming). `Lazy` stops after round 1 and defers the **window** of bitmap +/// words. Config-supplied V3 metadata is preserved unchanged. struct UniswapV3ColdStartPlanner { address: Address, layout: V3StorageLayout, policy: ColdStartPolicy, phase: V3Phase, - /// The current-tick bitmap word key, resolved from the warmed slot0. - bitmap_key: Option, - /// The current-tick bitmap word position, resolved from the warmed slot0. - word: i16, + /// The cold-start window: each `(word, bitmap_key)` pair in + /// `[W0 - R, W0 + R]` clamped to the valid V3 word range, resolved from the + /// warmed slot0. Empty until round 1 decodes the current tick. + window: Vec<(i16, U256)>, verified_slots: Vec<(Address, U256)>, changed_slots: Vec, deferred: Vec, @@ -281,8 +352,8 @@ struct UniswapV3ColdStartPlanner { enum V3Phase { /// Round 1: slot0 + liquidity (the next `on_results` classifies slot0). Slot0Liquidity, - /// Round 2: the current-tick bitmap word (the next `on_results` extracts - /// the initialized ticks). + /// Round 2: the window of bitmap words (the next `on_results` extracts the + /// initialized ticks across the whole window). BitmapWord, /// Round 3: the tick-info slots (the next `on_results` finishes). TickInfo, @@ -295,14 +366,40 @@ impl UniswapV3ColdStartPlanner { layout, policy, phase: V3Phase::Slot0Liquidity, - bitmap_key: None, - word: 0, + window: Vec::new(), verified_slots: Vec::new(), changed_slots: Vec::new(), deferred: Vec::new(), slot0_cold: false, } } + + /// Resolve the bounded window of bitmap words `[W0 - R, W0 + R]` around the + /// current-tick word, clamped to the valid V3 word range, returning each + /// `(word, bitmap_key)` pair. + /// + /// The clamp derives from `MIN_TICK`/`MAX_TICK = ±887272`: words outside the + /// pool's reachable word range are skipped. All arithmetic is done in `i32` + /// before the final `i16` cast so the radius offset can never overflow. + fn resolve_window(&self, current_word: i16) -> Vec<(i16, U256)> { + let radius = V3_TICK_WORD_RADIUS as i32; + let min_word = v3_word_position(V3_MIN_TICK, self.layout.tick_spacing) as i32; + let max_word = v3_word_position(V3_MAX_TICK, self.layout.tick_spacing) as i32; + + let lo = (current_word as i32 - radius).max(min_word); + let hi = (current_word as i32 + radius).min(max_word); + + let mut window = Vec::new(); + let mut word = lo; + while word <= hi { + let word_i16 = word as i16; + let key = + v3_tick_bitmap_storage_key_with_base(word_i16, self.layout.tick_bitmap_base_slot); + window.push((word_i16, key)); + word += 1; + } + window + } } impl AdapterColdStartPlanner for UniswapV3ColdStartPlanner { @@ -344,19 +441,21 @@ impl AdapterColdStartPlanner for UniswapV3ColdStartPlanner { }; // Decode the current tick from the warm slot0 word (bits - // [160, 184), 24-bit signed), reusing the reactive Swap decode. + // [160, 184), 24-bit signed), reusing the reactive Swap decode, + // then resolve the bounded window of bitmap words around it. let tick = int24_from_word(slot0 >> SLOT0_TICK_SHIFT); - let word = v3_word_position(tick, self.layout.tick_spacing); - let bitmap_key = - v3_tick_bitmap_storage_key_with_base(word, self.layout.tick_bitmap_base_slot); - self.word = word; - self.bitmap_key = Some(bitmap_key); + let current_word = v3_word_position(tick, self.layout.tick_spacing); + self.window = self.resolve_window(current_word); match self.policy { ColdStartPolicy::Strict | ColdStartPolicy::Eager => { - // Round 2: warm the bitmap word containing the current tick. + // Round 2: warm every bitmap word in the window in one round. self.phase = V3Phase::BitmapWord; - let verify = vec![(self.address, bitmap_key)]; + let verify: Vec<(Address, U256)> = self + .window + .iter() + .map(|(_, key)| (self.address, *key)) + .collect(); self.verified_slots.extend_from_slice(&verify); ColdStartStep::Continue(ColdStartPlan { verify, @@ -365,32 +464,43 @@ impl AdapterColdStartPlanner for UniswapV3ColdStartPlanner { } ColdStartPolicy::HotSlotsOnly => ColdStartStep::Done, ColdStartPolicy::Lazy => { - // Warm the hot slots now; defer the tick word. - self.deferred - .push(DeferredWork::VerifySlots(vec![(self.address, bitmap_key)])); + // Warm the hot slots now; defer the whole window of bitmap words. + let window_keys: Vec<(Address, U256)> = self + .window + .iter() + .map(|(_, key)| (self.address, *key)) + .collect(); + self.deferred.push(DeferredWork::VerifySlots(window_keys)); ColdStartStep::Done } } } V3Phase::BitmapWord => { - // Round 3: warm the {0, 3} info slots of every tick initialized in - // that word. The bitmap word is extracted adapter-locally: bit `i` - // set => tick `(word * 256 + i) * tick_spacing`. - let bitmap_key = self.bitmap_key.unwrap_or(U256::ZERO); - let bitmap = state - .storage(self.address, bitmap_key) - .unwrap_or(U256::ZERO); + // Round 3: warm the {0, 3} info slots of every tick initialized + // across the whole window. Each window word's bitmap is extracted + // adapter-locally: bit `i` set => tick `(word * 256 + i) * + // tick_spacing`, skipping any tick outside [MIN_TICK, MAX_TICK]. let mut tick_slots: Vec<(Address, U256)> = Vec::new(); - for bit in 0..256u32 { - if (bitmap >> bit) & U256::from(1) == U256::from(1) { - let tick_i = - (self.word as i32 * 256 + bit as i32) * self.layout.tick_spacing; - let keys = v3_tick_info_storage_keys_with_base( - tick_i, - self.layout.ticks_base_slot, - ); - tick_slots.push((self.address, keys[0])); - tick_slots.push((self.address, keys[3])); + for (word, bitmap_key) in &self.window { + let bitmap = state + .storage(self.address, *bitmap_key) + .unwrap_or(U256::ZERO); + for bit in 0..256u32 { + if (bitmap >> bit) & U256::from(1) == U256::from(1) { + // Compute the tick index in i32; word/bit/spacing are + // all bounded so this cannot overflow. + let tick_i = + (*word as i32 * 256 + bit as i32) * self.layout.tick_spacing; + if !(V3_MIN_TICK..=V3_MAX_TICK).contains(&tick_i) { + continue; + } + let keys = v3_tick_info_storage_keys_with_base( + tick_i, + self.layout.ticks_base_slot, + ); + tick_slots.push((self.address, keys[0])); + tick_slots.push((self.address, keys[3])); + } } } diff --git a/src/amm_wrapper.rs b/src/amm_wrapper.rs deleted file mode 100644 index a18ee42..0000000 --- a/src/amm_wrapper.rs +++ /dev/null @@ -1,249 +0,0 @@ -//! Unified AMM enum wrapping all supported pool types. - -use std::hash::{Hash, Hasher}; - -use alloy_eips::BlockId; -use alloy_network::Network; -use alloy_primitives::{Address, U256}; -use alloy_provider::Provider; -use amms::amms::{ - amm::AutomatedMarketMaker, erc_4626::ERC4626Vault, error::AMMError, uniswap_v2::UniswapV2Pool, - uniswap_v3::UniswapV3Pool, -}; - -use super::balancer_pool::BalancerPool; -use super::balancer_v3_pool::BalancerV3Pool; -use super::curve_pool::CurvePool; -use super::slipstream_pool::SlipstreamPool; -use super::solidly_v2_pool::SolidlyV2Pool; -use super::uniswap_v4_pool::UniswapV4Pool; - -/// Unified AMM enum replacing `amms::amms::amm::AMM` directly, -/// includes our custom BalancerPool implementation. -#[derive(Debug, Clone)] -pub enum LocalAMM { - UniswapV2(UniswapV2Pool), - UniswapV3(UniswapV3Pool), - PancakeSwapV3(UniswapV3Pool), - ERC4626(ERC4626Vault), - Balancer(BalancerPool), - BalancerV3(BalancerV3Pool), - Curve(CurvePool), - SolidlyV2(SolidlyV2Pool), - Slipstream(SlipstreamPool), - UniswapV4(UniswapV4Pool), -} - -/// Helper enum for type discrimination. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum Variant { - UniswapV2, - UniswapV3, - PancakeSwapV3, - ERC4626, - Balancer, - BalancerV3, - Curve, - SolidlyV2, - Slipstream, - UniswapV4, -} - -impl LocalAMM { - pub fn variant(&self) -> Variant { - match self { - LocalAMM::UniswapV2(_) => Variant::UniswapV2, - LocalAMM::UniswapV3(_) => Variant::UniswapV3, - LocalAMM::PancakeSwapV3(_) => Variant::PancakeSwapV3, - LocalAMM::ERC4626(_) => Variant::ERC4626, - LocalAMM::Balancer(_) => Variant::Balancer, - LocalAMM::BalancerV3(_) => Variant::BalancerV3, - LocalAMM::Curve(_) => Variant::Curve, - LocalAMM::SolidlyV2(_) => Variant::SolidlyV2, - LocalAMM::Slipstream(_) => Variant::Slipstream, - LocalAMM::UniswapV4(_) => Variant::UniswapV4, - } - } -} - -impl AutomatedMarketMaker for LocalAMM { - fn address(&self) -> Address { - match self { - LocalAMM::UniswapV2(pool) => pool.address(), - LocalAMM::UniswapV3(pool) | LocalAMM::PancakeSwapV3(pool) => pool.address(), - LocalAMM::ERC4626(pool) => pool.address(), - LocalAMM::Balancer(pool) => pool.address(), - LocalAMM::BalancerV3(pool) => pool.address(), - LocalAMM::Curve(pool) => pool.address(), - LocalAMM::SolidlyV2(pool) => pool.address(), - LocalAMM::Slipstream(pool) => pool.address(), - LocalAMM::UniswapV4(pool) => pool.address(), - } - } - - fn sync_events(&self) -> Vec { - match self { - LocalAMM::UniswapV2(pool) => pool.sync_events(), - LocalAMM::UniswapV3(pool) | LocalAMM::PancakeSwapV3(pool) => pool.sync_events(), - LocalAMM::ERC4626(pool) => pool.sync_events(), - LocalAMM::Balancer(pool) => pool.sync_events(), - LocalAMM::BalancerV3(pool) => pool.sync_events(), - LocalAMM::Curve(pool) => pool.sync_events(), - LocalAMM::SolidlyV2(pool) => pool.sync_events(), - LocalAMM::Slipstream(pool) => pool.sync_events(), - LocalAMM::UniswapV4(pool) => pool.sync_events(), - } - } - - fn sync(&mut self, log: &alloy_rpc_types_eth::Log) -> Result<(), AMMError> { - match self { - LocalAMM::UniswapV2(pool) => pool.sync(log), - LocalAMM::UniswapV3(pool) | LocalAMM::PancakeSwapV3(pool) => pool.sync(log), - LocalAMM::ERC4626(pool) => pool.sync(log), - LocalAMM::Balancer(pool) => pool.sync(log), - LocalAMM::BalancerV3(pool) => pool.sync(log), - LocalAMM::Curve(pool) => pool.sync(log), - LocalAMM::SolidlyV2(pool) => pool.sync(log), - LocalAMM::Slipstream(pool) => pool.sync(log), - LocalAMM::UniswapV4(pool) => pool.sync(log), - } - } - - fn tokens(&self) -> Vec
{ - match self { - LocalAMM::UniswapV2(pool) => pool.tokens(), - LocalAMM::UniswapV3(pool) | LocalAMM::PancakeSwapV3(pool) => pool.tokens(), - LocalAMM::ERC4626(pool) => pool.tokens(), - LocalAMM::Balancer(pool) => pool.tokens(), - LocalAMM::BalancerV3(pool) => pool.tokens(), - LocalAMM::Curve(pool) => pool.tokens(), - LocalAMM::SolidlyV2(pool) => pool.tokens(), - LocalAMM::Slipstream(pool) => pool.tokens(), - LocalAMM::UniswapV4(pool) => pool.tokens(), - } - } - - fn calculate_price(&self, base_token: Address, quote_token: Address) -> Result { - match self { - LocalAMM::UniswapV2(pool) => pool.calculate_price(base_token, quote_token), - LocalAMM::UniswapV3(pool) | LocalAMM::PancakeSwapV3(pool) => { - pool.calculate_price(base_token, quote_token) - } - LocalAMM::ERC4626(pool) => pool.calculate_price(base_token, quote_token), - LocalAMM::Balancer(pool) => pool.calculate_price(base_token, quote_token), - LocalAMM::BalancerV3(pool) => pool.calculate_price(base_token, quote_token), - LocalAMM::Curve(pool) => pool.calculate_price(base_token, quote_token), - LocalAMM::SolidlyV2(pool) => pool.calculate_price(base_token, quote_token), - LocalAMM::Slipstream(pool) => pool.calculate_price(base_token, quote_token), - LocalAMM::UniswapV4(pool) => pool.calculate_price(base_token, quote_token), - } - } - - fn simulate_swap( - &self, - base_token: Address, - quote_token: Address, - amount_in: U256, - ) -> Result { - match self { - LocalAMM::UniswapV2(pool) => pool.simulate_swap(base_token, quote_token, amount_in), - LocalAMM::UniswapV3(pool) | LocalAMM::PancakeSwapV3(pool) => { - pool.simulate_swap(base_token, quote_token, amount_in) - } - LocalAMM::ERC4626(pool) => pool.simulate_swap(base_token, quote_token, amount_in), - LocalAMM::Balancer(pool) => pool.simulate_swap(base_token, quote_token, amount_in), - LocalAMM::BalancerV3(pool) => pool.simulate_swap(base_token, quote_token, amount_in), - LocalAMM::Curve(pool) => pool.simulate_swap(base_token, quote_token, amount_in), - LocalAMM::SolidlyV2(pool) => pool.simulate_swap(base_token, quote_token, amount_in), - LocalAMM::Slipstream(pool) => pool.simulate_swap(base_token, quote_token, amount_in), - LocalAMM::UniswapV4(pool) => pool.simulate_swap(base_token, quote_token, amount_in), - } - } - - fn simulate_swap_mut( - &mut self, - base_token: Address, - quote_token: Address, - amount_in: U256, - ) -> Result { - match self { - LocalAMM::UniswapV2(pool) => pool.simulate_swap_mut(base_token, quote_token, amount_in), - LocalAMM::UniswapV3(pool) | LocalAMM::PancakeSwapV3(pool) => { - pool.simulate_swap_mut(base_token, quote_token, amount_in) - } - LocalAMM::ERC4626(pool) => pool.simulate_swap_mut(base_token, quote_token, amount_in), - LocalAMM::Balancer(pool) => pool.simulate_swap_mut(base_token, quote_token, amount_in), - LocalAMM::BalancerV3(pool) => { - pool.simulate_swap_mut(base_token, quote_token, amount_in) - } - LocalAMM::Curve(pool) => pool.simulate_swap_mut(base_token, quote_token, amount_in), - LocalAMM::SolidlyV2(pool) => pool.simulate_swap_mut(base_token, quote_token, amount_in), - LocalAMM::Slipstream(pool) => { - pool.simulate_swap_mut(base_token, quote_token, amount_in) - } - LocalAMM::UniswapV4(pool) => pool.simulate_swap_mut(base_token, quote_token, amount_in), - } - } - - async fn init(self, block_number: BlockId, provider: P) -> Result - where - Self: Sized, - N: Network, - P: Provider + Clone, - { - match self { - LocalAMM::UniswapV2(pool) => pool - .init(block_number, provider) - .await - .map(LocalAMM::UniswapV2), - LocalAMM::UniswapV3(pool) => pool - .init(block_number, provider) - .await - .map(LocalAMM::UniswapV3), - LocalAMM::PancakeSwapV3(pool) => pool - .init(block_number, provider) - .await - .map(LocalAMM::PancakeSwapV3), - LocalAMM::ERC4626(pool) => pool - .init(block_number, provider) - .await - .map(LocalAMM::ERC4626), - LocalAMM::Balancer(pool) => pool - .init(block_number, provider) - .await - .map(LocalAMM::Balancer), - LocalAMM::BalancerV3(pool) => pool - .init(block_number, provider) - .await - .map(LocalAMM::BalancerV3), - LocalAMM::Curve(pool) => pool.init(block_number, provider).await.map(LocalAMM::Curve), - LocalAMM::SolidlyV2(pool) => pool - .init(block_number, provider) - .await - .map(LocalAMM::SolidlyV2), - LocalAMM::Slipstream(pool) => pool - .init(block_number, provider) - .await - .map(LocalAMM::Slipstream), - LocalAMM::UniswapV4(pool) => pool - .init(block_number, provider) - .await - .map(LocalAMM::UniswapV4), - } - } -} - -// Hash & Eq by address, like the original AMM did. -impl Hash for LocalAMM { - fn hash(&self, state: &mut H) { - self.address().hash(state); - } -} - -impl PartialEq for LocalAMM { - fn eq(&self, other: &Self) -> bool { - self.address() == other.address() - } -} - -impl Eq for LocalAMM {} diff --git a/src/balancer_pool.rs b/src/balancer_pool.rs deleted file mode 100644 index 12e9d42..0000000 --- a/src/balancer_pool.rs +++ /dev/null @@ -1,323 +0,0 @@ -//! Balancer weighted pool wrapper implementing the AutomatedMarketMaker trait. - -use std::collections::HashMap; - -use super::balancer_math::{WeightedPool, WeightedPoolError, u256_to_f64_lossy}; -use super::data::PoolParams; - -use alloy_eips::BlockId; -use alloy_network::Network; -use alloy_primitives::{Address, B256, U256}; -use alloy_provider::{MulticallError, Provider}; - -use alloy_rpc_types_eth::Log; -use alloy_sol_types::sol; - -use amms::amms::{amm::AutomatedMarketMaker, balancer::BalancerError, error::AMMError}; -use serde::{Deserialize, Serialize}; - -sol! { - #[sol(rpc)] - contract IERC20 { - function decimals() external view returns (uint8); - } -} - -sol! { - #[sol(rpc)] - contract IBalancerPool { - function getNormalizedWeights() external view returns (uint256[] memory); - function getSwapFeePercentage() external view returns (uint256); - } -} - -sol! { - #[sol(rpc)] - contract IBalancerVault { - function getPoolTokens(bytes32 poolId) - external - view - returns (address[] memory tokens, uint256[] memory balances, uint256 lastChangeBlock); - } -} - -/// Lossy conversion from f64 "real units" back to on-chain U256. -pub fn f64_to_u256_lossy(value: f64, decimals: u8) -> U256 { - if value <= 0.0 { - return U256::ZERO; - } - let scale = 10f64.powi(decimals as i32); - let scaled = (value * scale).floor(); - U256::from(scaled as u128) -} - -/// A Balancer-weighted pool wrapper that uses `WeightedPool` -/// math but implements the `AutomatedMarketMaker` trait. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BalancerPool { - /// Address of the Balancer vault - pub vault: Address, - /// Optional Balancer Vault poolId if you want to keep it around - pub pool_id: B256, - /// Core math / balances / weights in real units - inner: WeightedPool, - /// Token -> decimals map used for U256 <-> f64 conversion - decimals: HashMap, -} - -impl BalancerPool { - /// Constructor from pre-computed weights. - pub fn from_weights( - pool_id: B256, - vault: Address, - inner: WeightedPool, - decimals: HashMap, - ) -> Self { - Self { - vault, - pool_id, - inner, - decimals, - } - } - - /// Minimal constructor: just the pool ID and vault address. - /// `init` will later populate `inner` and `decimals`. - pub fn new(pool_id: B256, vault: Address) -> Self { - Self { - vault, - pool_id, - inner: WeightedPool { - tokens: Vec::new(), - balances: Vec::new(), - weights: Vec::new(), - swap_fee: 0.0, - }, - decimals: HashMap::new(), - } - } - - /// Rebuild the inner WeightedPool with fresh balances from PoolParams. - pub fn refresh_from_params(&mut self, params: &PoolParams, decimals: &HashMap) { - self.inner = WeightedPool::from_params(params, decimals); - } - - /// Apply a Balancer V2 vault `Swap` event in place. - /// - /// The vault emits `Swap(poolId, tokenIn, tokenOut, amountIn, amountOut)`, - /// which is enough to update the affected pool's balances exactly without an - /// RPC round-trip: `balance[tokenIn] += amountIn` and - /// `balance[tokenOut] -= amountOut`. Amounts are raw on-chain values and are - /// converted to the pool's internal real-unit representation using the - /// per-token decimals captured during initialization. Tokens not held by - /// this pool are ignored. Returns `true` if any balance changed. - pub fn apply_vault_swap( - &mut self, - token_in: Address, - amount_in: U256, - token_out: Address, - amount_out: U256, - ) -> bool { - let mut changed = false; - if let Some(idx) = self.inner.tokens.iter().position(|t| *t == token_in) { - let delta = u256_to_f64_lossy(amount_in, self.token_decimals(token_in)); - self.inner.balances[idx] += delta; - changed = true; - } - if let Some(idx) = self.inner.tokens.iter().position(|t| *t == token_out) { - let delta = u256_to_f64_lossy(amount_out, self.token_decimals(token_out)); - self.inner.balances[idx] = (self.inner.balances[idx] - delta).max(0.0); - changed = true; - } - changed - } - - /// Return current balances as (token, U256) pairs for on-chain comparison. - pub fn balances_u256(&self) -> Vec<(Address, U256)> { - self.inner - .tokens - .iter() - .zip(self.inner.balances.iter()) - .map(|(&token, &balance)| { - let decimals = self.decimals.get(&token).copied().unwrap_or(18); - (token, f64_to_u256_lossy(balance, decimals)) - }) - .collect() - } - - /// Helper: get decimals for a token (default 18 if missing). - fn token_decimals(&self, token: Address) -> u8 { - self.decimals.get(&token).copied().unwrap_or(18) - } - - pub fn address(pool_id: B256) -> Address { - Address::from_slice(&pool_id[0..20]) - } - - pub async fn get_pool_params( - provider: &P, - vault: Address, - pool_id: B256, - ) -> Result<(WeightedPool, HashMap), AMMError> - where - P: Provider + Clone, - N: Network, - { - let pool_addr = Self::address(pool_id); - let pool = IBalancerPool::IBalancerPoolInstance::new(pool_addr, provider); - let vault = IBalancerVault::IBalancerVaultInstance::new(vault, provider); - - let multicall = &provider - .multicall() - .add(vault.getPoolTokens(pool_id)) - .add(pool.getNormalizedWeights()) - .add(pool.getSwapFeePercentage()); - let (tokens, normalized_weights, swap_fee) = - multicall.aggregate().await.map_err(map_multicall_error)?; - - let params = PoolParams::new_from_parts( - tokens.tokens, - tokens.balances, - normalized_weights, - swap_fee, - ); - - // Fetch decimals for all tokens in this pool - let mut dynamic_multicall = provider.multicall().dynamic(); - for token in params.tokens() { - dynamic_multicall = - dynamic_multicall.add_dynamic(IERC20::new(token, &provider).decimals()); - } - let decimals = dynamic_multicall - .aggregate() - .await - .map_err(map_multicall_error)? - .into_iter() - .zip(params.tokens()) - .map(|(decimals, token)| (token, decimals)) - .collect::>(); - - Ok((WeightedPool::from_params(¶ms, &decimals), decimals)) - } -} - -#[allow(async_fn_in_trait)] -impl AutomatedMarketMaker for BalancerPool { - fn address(&self) -> Address { - Self::address(self.pool_id) - } - - fn sync_events(&self) -> Vec { - Vec::new() - } - - fn sync(&mut self, _log: &Log) -> Result<(), AMMError> { - Ok(()) - } - - fn tokens(&self) -> Vec
{ - self.inner.tokens.clone() - } - - fn calculate_price(&self, base_token: Address, quote_token: Address) -> Result { - if base_token == quote_token { - return Ok(1.0); - } - - let price = self - .inner - .spot_price(base_token, quote_token) - .map_err(map_weighted_pool_error)?; - - Ok(price) - } - - fn simulate_swap( - &self, - base_token: Address, - quote_token: Address, - amount_in: U256, - ) -> Result { - if amount_in.is_zero() { - return Ok(U256::ZERO); - } - - let dec_in = self.token_decimals(base_token); - let dec_out = self.token_decimals(quote_token); - - let amount_in_f = u256_to_f64_lossy(amount_in, dec_in); - - if amount_in_f <= 0.0 { - return Ok(U256::ZERO); - } - - // non-mutating: clone the inner pool - let mut tmp = self.inner.clone(); - - let out_f = tmp - .swap_out_given_in(base_token, quote_token, amount_in_f) - .map_err(map_weighted_pool_error)?; - - let out_u = f64_to_u256_lossy(out_f, dec_out); - Ok(out_u) - } - - fn simulate_swap_mut( - &mut self, - base_token: Address, - quote_token: Address, - amount_in: U256, - ) -> Result { - if amount_in.is_zero() { - return Ok(U256::ZERO); - } - - let dec_in = self.token_decimals(base_token); - let dec_out = self.token_decimals(quote_token); - - let amount_in_f = u256_to_f64_lossy(amount_in, dec_in); - - if amount_in_f <= 0.0 { - return Ok(U256::ZERO); - } - - let out_f = self - .inner - .swap_out_given_in(base_token, quote_token, amount_in_f) - .map_err(map_weighted_pool_error)?; - - let out_u = f64_to_u256_lossy(out_f, dec_out); - Ok(out_u) - } - - async fn init(mut self, _block_number: BlockId, provider: P) -> Result - where - Self: Sized, - N2: Network, - P: Provider + Clone, - { - let (inner, decimals) = Self::get_pool_params(&provider, self.vault, self.pool_id).await?; - self.inner = inner; - self.decimals = decimals; - Ok(self) - } -} - -fn map_multicall_error(e: MulticallError) -> AMMError { - match e { - MulticallError::TransportError(e) => AMMError::TransportError(e), - MulticallError::DecodeError(e) => AMMError::SolTypesError(e), - _ => AMMError::BalancerError(BalancerError::InitializationError), - } -} - -fn map_weighted_pool_error(e: WeightedPoolError) -> AMMError { - match e { - WeightedPoolError::TokenInDoesNotExist => { - AMMError::from(BalancerError::TokenInDoesNotExist) - } - WeightedPoolError::TokenOutDoesNotExist => { - AMMError::from(BalancerError::TokenOutDoesNotExist) - } - } -} diff --git a/src/balancer_v3_pool.rs b/src/balancer_v3_pool.rs deleted file mode 100644 index 5bf686b..0000000 --- a/src/balancer_v3_pool.rs +++ /dev/null @@ -1,286 +0,0 @@ -//! Balancer V3 weighted pool with local simulation. -//! -//! Reuses the weighted-pool math from `balancer_math.rs`. Pools must be -//! populated with balances, weights, and swap fee before `simulate_swap` -//! returns meaningful results. - -use alloy_eips::BlockId; -use alloy_network::Network; -use alloy_primitives::{Address, B256, U256}; -use alloy_provider::Provider; -use alloy_rpc_types_eth::Log; -use amms::amms::{amm::AutomatedMarketMaker, balancer::BalancerError, error::AMMError}; - -use super::balancer_math::{WeightedPool, WeightedPoolError, u256_to_f64_lossy}; -use super::stableswap_math; - -/// Discriminates between Balancer V3 weighted and stable pool types. -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum BalancerV3PoolType { - Weighted, - Stable, -} - -#[derive(Debug, Clone)] -pub struct BalancerV3Pool { - pub address: Address, - pub vault: Address, - pub token_a: Address, - pub token_b: Address, - /// Token balances in raw units (token_a at index 0, token_b at index 1). - pub balances: [U256; 2], - /// Normalized weights (1e18 fixed point). Must sum to ~1e18. - /// For stable pools these are set to 50/50 but unused by simulation. - pub weights: [U256; 2], - /// Swap fee in 1e18 fixed point (e.g. 5e14 = 0.05% = 5 bps). - pub swap_fee: U256, - /// Decimals for each token. - pub decimals: [u8; 2], - /// Pool type: Weighted uses power-law math, Stable uses StableSwap invariant. - pub pool_type: BalancerV3PoolType, - /// Amplification factor in Curve convention: `_A() = A * A_PRECISION`. - /// Only set for Stable pools. Balancer returns `(value, isUpdating, precision)` - /// where `value = A * precision` (precision=1000); we convert to Curve's - /// `_A() = A * 100` via `value * 100 / precision`. - pub amplification_factor: Option, -} - -impl BalancerV3Pool { - /// Returns true if the pool has been populated with state for simulation. - pub fn is_initialized(&self) -> bool { - let balances_ok = !self.balances[0].is_zero() && !self.balances[1].is_zero(); - match self.pool_type { - BalancerV3PoolType::Weighted => { - balances_ok && !self.weights[0].is_zero() && !self.weights[1].is_zero() - } - BalancerV3PoolType::Stable => balances_ok && self.amplification_factor.is_some(), - } - } - - /// Build a `WeightedPool` from the current state for simulation (weighted pools only). - fn as_weighted_pool(&self) -> WeightedPool { - let bal_a = u256_to_f64_lossy(self.balances[0], self.decimals[0]); - let bal_b = u256_to_f64_lossy(self.balances[1], self.decimals[1]); - let w_a = u256_to_f64_lossy(self.weights[0], 18); - let w_b = u256_to_f64_lossy(self.weights[1], 18); - let sum_w = w_a + w_b; - WeightedPool { - tokens: vec![self.token_a, self.token_b], - balances: vec![bal_a, bal_b], - weights: vec![w_a / sum_w, w_b / sum_w], - swap_fee: u256_to_f64_lossy(self.swap_fee, 18), - } - } - - /// Compute precision multipliers for StableSwap math: `10^(18 - decimals[i])`. - /// - /// Decimals originate from an on-chain `decimals()` read (defaulting to 18), - /// so a misbehaving or non-standard token can report a value above 18. In - /// that case `18 - decimals[i]` would underflow (panicking in debug, wrapping - /// in release), so we saturate the multiplier to 1 (i.e. skip upscaling) - /// rather than panicking. Mirrors the guard in `cache_sync::curve_sync`. - fn precision_multipliers(&self) -> [U256; 2] { - let multiplier = |decimals: u8| -> U256 { - if decimals <= 18 { - U256::from(10u64).pow(U256::from(18 - decimals)) - } else { - U256::from(1) - } - }; - [multiplier(self.decimals[0]), multiplier(self.decimals[1])] - } - - /// Convert Balancer V3's 1e18 fixed-point fee to Curve's parts-per-1e10 format. - /// E.g. 5e14 (0.05% in 1e18) → 5_000_000 (0.05% in 1e10). - fn fee_as_curve_parts(&self) -> U256 { - self.swap_fee / U256::from(100_000_000u64) // / 1e8 - } - - /// Resolve token addresses to (i, j) indices. - fn token_indices(&self, base: Address, quote: Address) -> Result<(usize, usize), AMMError> { - let i = if base == self.token_a { - 0 - } else if base == self.token_b { - 1 - } else { - return Err(AMMError::from(BalancerError::TokenInDoesNotExist)); - }; - let j = if quote == self.token_a { - 0 - } else if quote == self.token_b { - 1 - } else { - return Err(AMMError::from(BalancerError::TokenOutDoesNotExist)); - }; - Ok((i, j)) - } -} - -impl AutomatedMarketMaker for BalancerV3Pool { - fn address(&self) -> Address { - self.address - } - - fn sync_events(&self) -> Vec { - vec![] - } - - fn sync(&mut self, _log: &Log) -> Result<(), AMMError> { - Ok(()) - } - - fn tokens(&self) -> Vec
{ - vec![self.token_a, self.token_b] - } - - fn calculate_price(&self, base: Address, quote: Address) -> Result { - if !self.is_initialized() { - return Err(AMMError::from(BalancerError::InitializationError)); - } - match self.pool_type { - BalancerV3PoolType::Weighted => self - .as_weighted_pool() - .spot_price(base, quote) - .map_err(map_weighted_pool_error), - BalancerV3PoolType::Stable => { - let (i, j) = self.token_indices(base, quote)?; - let pm = self.precision_multipliers(); - let a = self.amplification_factor.unwrap(); // checked by is_initialized - let fee = self.fee_as_curve_parts(); - // 1 unit in base token's native decimals - let pm_i: u128 = pm[i].try_into().unwrap_or(1); - let one_unit = U256::from(1_000_000_000_000_000_000u128 / pm_i); - let out = stableswap_math::stableswap_get_dy( - &self.balances, - pm.as_ref(), - a, - fee, - i, - j, - one_unit, - ) - .unwrap_or(U256::ZERO); - let pm_j: u128 = pm[j].try_into().unwrap_or(1); - let out_18: u128 = (out * U256::from(pm_j)).try_into().unwrap_or(0); - Ok(out_18 as f64 / 1e18) - } - } - } - - fn simulate_swap( - &self, - base: Address, - quote: Address, - amount_in: U256, - ) -> Result { - if !self.is_initialized() { - return Err(AMMError::from(BalancerError::InitializationError)); - } - match self.pool_type { - BalancerV3PoolType::Weighted => { - let mut wp = self.as_weighted_pool(); - let in_decimals = if base == self.token_a { - self.decimals[0] - } else { - self.decimals[1] - }; - let amount_in_f64 = u256_to_f64_lossy(amount_in, in_decimals); - let out_f64 = wp - .swap_out_given_in(base, quote, amount_in_f64) - .map_err(map_weighted_pool_error)?; - let out_decimals = if quote == self.token_a { - self.decimals[0] - } else { - self.decimals[1] - }; - let scale = 10f64.powi(out_decimals as i32); - Ok(U256::from((out_f64 * scale) as u128)) - } - BalancerV3PoolType::Stable => { - let (i, j) = self.token_indices(base, quote)?; - let a = self.amplification_factor.unwrap(); - let pm = self.precision_multipliers(); - let fee = self.fee_as_curve_parts(); - stableswap_math::stableswap_get_dy( - &self.balances, - pm.as_ref(), - a, - fee, - i, - j, - amount_in, - ) - .ok_or_else(|| AMMError::from(BalancerError::InitializationError)) - } - } - } - - fn simulate_swap_mut( - &mut self, - base: Address, - quote: Address, - amount_in: U256, - ) -> Result { - if !self.is_initialized() { - return Err(AMMError::from(BalancerError::InitializationError)); - } - match self.pool_type { - BalancerV3PoolType::Weighted => { - let mut wp = self.as_weighted_pool(); - let in_decimals = if base == self.token_a { - self.decimals[0] - } else { - self.decimals[1] - }; - let out_decimals = if quote == self.token_a { - self.decimals[0] - } else { - self.decimals[1] - }; - let amount_in_f64 = u256_to_f64_lossy(amount_in, in_decimals); - let out_f64 = wp - .swap_out_given_in(base, quote, amount_in_f64) - .map_err(map_weighted_pool_error)?; - - // Write back updated balances - let idx_a = wp.index_of(self.token_a).unwrap_or(0); - let idx_b = wp.index_of(self.token_b).unwrap_or(1); - let scale_a = 10f64.powi(self.decimals[0] as i32); - let scale_b = 10f64.powi(self.decimals[1] as i32); - self.balances[0] = U256::from((wp.balances[idx_a] * scale_a) as u128); - self.balances[1] = U256::from((wp.balances[idx_b] * scale_b) as u128); - - let scale = 10f64.powi(out_decimals as i32); - Ok(U256::from((out_f64 * scale) as u128)) - } - BalancerV3PoolType::Stable => { - let (i, j) = self.token_indices(base, quote)?; - let result = self.simulate_swap(base, quote, amount_in)?; - // Update reserves - self.balances[i] += amount_in; - self.balances[j] = self.balances[j].saturating_sub(result); - Ok(result) - } - } - } - - async fn init(self, _block: BlockId, _provider: P) -> Result - where - N: Network, - P: Provider + Clone, - { - Ok(self) - } -} - -fn map_weighted_pool_error(e: WeightedPoolError) -> AMMError { - match e { - WeightedPoolError::TokenInDoesNotExist => { - AMMError::from(BalancerError::TokenInDoesNotExist) - } - WeightedPoolError::TokenOutDoesNotExist => { - AMMError::from(BalancerError::TokenOutDoesNotExist) - } - } -} diff --git a/src/cache_sync/adaptive_prefetch.rs b/src/cache_sync/adaptive_prefetch.rs deleted file mode 100644 index c295ead..0000000 --- a/src/cache_sync/adaptive_prefetch.rs +++ /dev/null @@ -1,537 +0,0 @@ -use std::time::Duration; - -use alloy_transport_balancer::weighted_domain_backoff; -use futures::future::join_all; -use tracing::{debug, info, warn}; - -use crate::progress::{finish_with_message, progress_bar}; -use crate::tuning::{SyncSpeedMode, sync_speed_mode}; - -use super::{max_concurrent_storage_prefetch, prefetch_inter_chunk_delay}; - -/// Configuration for adaptive prefetch behavior. -/// Each prefetch invocation creates a fresh instance — no cross-call persistence. -pub(crate) struct AdaptivePrefetchConfig { - /// Starting (and maximum) chunk size. - pub initial_chunk_size: usize, - /// Minimum chunk size (floor for adaptive reduction). - pub min_chunk_size: usize, - /// Amount to grow chunk size by on a zero-failure chunk. - pub chunk_size_grow: usize, - /// Starting inter-chunk delay. - pub initial_delay: Duration, - /// Minimum inter-chunk delay. - pub min_delay: Duration, - /// Maximum inter-chunk delay. - pub max_delay: Duration, - /// Failure rate threshold (0.0–1.0) above which throttling kicks in. - pub failure_threshold: f64, - /// Maximum total attempts per request (1 = no retries, 3 = up to 2 retries). - pub max_attempts: u32, -} - -impl Default for AdaptivePrefetchConfig { - fn default() -> Self { - let (min_chunk, grow) = match sync_speed_mode() { - SyncSpeedMode::Fast => (5, 4), - SyncSpeedMode::Normal => (4, 3), - SyncSpeedMode::Slow => (3, 2), - SyncSpeedMode::XSlow => (1, 1), - }; - Self { - initial_chunk_size: max_concurrent_storage_prefetch(), - min_chunk_size: min_chunk, - chunk_size_grow: grow, - initial_delay: prefetch_inter_chunk_delay(), - min_delay: prefetch_inter_chunk_delay(), - max_delay: Duration::from_secs(2), - failure_threshold: 0.25, - max_attempts: 3, - } - } -} - -/// Minimum chunk size when domain throttling is active. -/// Lower than the default min (5) to allow very conservative prefetching -/// when RPC providers are severely rate-limited. -const THROTTLED_MIN_CHUNK_SIZE: usize = 2; - -impl AdaptivePrefetchConfig { - /// Create a config that proactively reduces concurrency when RPC domains - /// are already being rate-limited. - /// - /// Queries the global cross-thread domain throttle state and scales down - /// `initial_chunk_size` based on the worst-case domain backoff delay: - /// - 0ms delay (no throttling): full chunk size (40) - /// - 50ms delay (level 1): 50% → 20 chunks - /// - 150ms delay (level 2): 25% → 10 chunks - /// - 400ms delay (level 3): 10% → 4 chunks - /// - 1000ms+ delay (level 4+): 5% → 2 chunks - /// - /// Also reduces the adaptive floor (`min_chunk_size`) and raises the - /// inter-chunk delay to match the domain backoff, so the reactive - /// throttle doesn't immediately bottom out. - pub fn throttle_aware() -> Self { - let base = Self::default(); - let max_delay = weighted_domain_backoff(); - let delay_ms = max_delay.as_millis() as u64; - - if delay_ms == 0 { - return base; - } - - let scale = if delay_ms <= 50 { - 0.50 - } else if delay_ms <= 150 { - 0.25 - } else if delay_ms <= 400 { - 0.10 - } else { - 0.05 - }; - - let scaled_chunk = - ((base.initial_chunk_size as f64 * scale) as usize).max(THROTTLED_MIN_CHUNK_SIZE); - - // Also lower the floor so reactive throttle has room to reduce further - let min_chunk = if delay_ms >= 400 { - THROTTLED_MIN_CHUNK_SIZE - } else { - THROTTLED_MIN_CHUNK_SIZE.max(scaled_chunk / 2) - }; - - // Match inter-chunk delay to domain backoff — no point sending the - // next chunk faster than the transport will delay each request anyway. - let initial_delay = base.initial_delay.max(Duration::from_millis(delay_ms)); - - info!( - domain_delay_ms = delay_ms, - default_chunk = base.initial_chunk_size, - throttled_chunk = scaled_chunk, - min_chunk, - initial_delay_ms = initial_delay.as_millis() as u64, - "adaptive prefetch: proactively reducing concurrency due to domain throttling" - ); - - Self { - initial_chunk_size: scaled_chunk, - min_chunk_size: min_chunk, - initial_delay, - // Also raise the minimum delay floor so recovery doesn't drop below domain backoff - min_delay: base.min_delay.max(Duration::from_millis(delay_ms / 2)), - ..base - } - } -} - -/// Result of a single adaptive prefetch run. -pub(crate) struct AdaptivePrefetchResult { - pub success_count: usize, - pub error_count: usize, - pub retry_rounds: u32, -} - -/// Mutable state tracked during a single prefetch invocation. -pub(crate) struct AdaptiveState { - pub current_chunk_size: usize, - pub current_delay: Duration, - config: AdaptivePrefetchConfig, -} - -impl AdaptiveState { - pub fn new(config: AdaptivePrefetchConfig) -> Self { - let chunk_size = config.initial_chunk_size; - let delay = config.initial_delay; - Self { - current_chunk_size: chunk_size, - current_delay: delay, - config, - } - } - - /// Adjust state after processing a chunk. - pub fn adjust_after_chunk(&mut self, total: usize, failures: usize) { - if total == 0 { - return; - } - let failure_rate = failures as f64 / total as f64; - if failure_rate > self.config.failure_threshold { - // Throttle: halve chunk size, double delay - let old_chunk = self.current_chunk_size; - let old_delay = self.current_delay; - self.current_chunk_size = (self.current_chunk_size / 2).max(self.config.min_chunk_size); - self.current_delay = (self.current_delay * 2).min(self.config.max_delay); - warn!( - failure_rate = format!("{:.1}%", failure_rate * 100.0), - old_chunk_size = old_chunk, - new_chunk_size = self.current_chunk_size, - old_delay_ms = old_delay.as_millis() as u64, - new_delay_ms = self.current_delay.as_millis() as u64, - "adaptive throttle: reducing batch size and increasing delay" - ); - } else if failures == 0 { - // Recover: grow chunk size, halve delay - let old_chunk = self.current_chunk_size; - self.current_chunk_size = (self.current_chunk_size + self.config.chunk_size_grow) - .min(self.config.initial_chunk_size); - self.current_delay = Duration::from_millis( - (self.current_delay.as_millis() as u64 / 2) - .max(self.config.min_delay.as_millis() as u64), - ); - if old_chunk != self.current_chunk_size { - debug!( - old_chunk_size = old_chunk, - new_chunk_size = self.current_chunk_size, - delay_ms = self.current_delay.as_millis() as u64, - "adaptive recovery: growing batch size" - ); - } - } - // else: some failures but below threshold — hold steady - } -} - -/// Run an adaptive prefetch over a list of request items. -/// -/// `spawn_fetch` is called for each request item to create a `JoinHandle`. -/// It receives the request item and a global index. The `JoinHandle` should -/// resolve to `Ok(())` on success or `Err(global_index)` on failure, so -/// that failed items can be queued for retry. -/// -/// Progress is reported via `progress_label`. After the primary pass, any -/// failed items are retried up to `config.max_attempts - 1` additional -/// rounds using the (potentially throttled) adaptive state. -pub(crate) async fn run_adaptive_prefetch( - requests: &[T], - config: AdaptivePrefetchConfig, - progress_label: &str, - spawn_fetch: F, -) -> AdaptivePrefetchResult -where - T: Sync, - F: Fn(&T, usize) -> tokio::task::JoinHandle>, -{ - if requests.is_empty() { - return AdaptivePrefetchResult { - success_count: 0, - error_count: 0, - retry_rounds: 0, - }; - } - - let max_attempts = config.max_attempts; - let mut state = AdaptiveState::new(config); - - let pb = progress_bar(requests.len() as u64, progress_label); - - let mut success_count = 0usize; - let mut failed_indices: Vec = Vec::new(); - - // Primary pass - let mut offset = 0; - while offset < requests.len() { - let chunk_end = (offset + state.current_chunk_size).min(requests.len()); - let chunk = &requests[offset..chunk_end]; - - let handles: Vec<_> = chunk - .iter() - .enumerate() - .map(|(local_idx, item)| { - let global_idx = offset + local_idx; - spawn_fetch(item, global_idx) - }) - .collect(); - - let results = join_all(handles).await; - let chunk_total = results.len(); - let mut chunk_failures = 0; - - for result in results { - match result { - Ok(Ok(())) => success_count += 1, - Ok(Err(idx)) => { - failed_indices.push(idx); - chunk_failures += 1; - } - Err(join_err) => { - debug!("spawn_blocking join error during prefetch: {}", join_err); - chunk_failures += 1; - } - } - pb.inc(1); - } - - state.adjust_after_chunk(chunk_total, chunk_failures); - tokio::time::sleep(state.current_delay).await; - offset = chunk_end; - } - - finish_with_message( - &pb, - &format!("{} fetched, {} failed", success_count, failed_indices.len()), - ); - - // Retry rounds - let mut retry_rounds = 0u32; - while !failed_indices.is_empty() && retry_rounds < max_attempts - 1 { - retry_rounds += 1; - let retry_count = failed_indices.len(); - warn!( - retry_round = retry_rounds, - items = retry_count, - chunk_size = state.current_chunk_size, - delay_ms = state.current_delay.as_millis() as u64, - "retrying failed prefetch requests" - ); - - let retry_pb = progress_bar( - retry_count as u64, - &format!("{} retry {}", progress_label, retry_rounds), - ); - - let mut still_failed: Vec = Vec::new(); - let mut retry_offset = 0; - let mut retry_success = 0usize; - - while retry_offset < failed_indices.len() { - let chunk_end = (retry_offset + state.current_chunk_size).min(failed_indices.len()); - let chunk_indices = &failed_indices[retry_offset..chunk_end]; - - let handles: Vec<_> = chunk_indices - .iter() - .map(|&idx| spawn_fetch(&requests[idx], idx)) - .collect(); - - let results = join_all(handles).await; - let chunk_total = results.len(); - let mut chunk_failures = 0; - - for result in results { - match result { - Ok(Ok(())) => { - retry_success += 1; - success_count += 1; - } - Ok(Err(idx)) => { - still_failed.push(idx); - chunk_failures += 1; - } - Err(join_err) => { - debug!("spawn_blocking join error during retry: {}", join_err); - chunk_failures += 1; - } - } - retry_pb.inc(1); - } - - state.adjust_after_chunk(chunk_total, chunk_failures); - tokio::time::sleep(state.current_delay).await; - retry_offset = chunk_end; - } - - finish_with_message( - &retry_pb, - &format!( - "{} recovered, {} still failed", - retry_success, - still_failed.len() - ), - ); - - failed_indices = still_failed; - } - - let error_count = failed_indices.len(); - if error_count > 0 { - warn!( - success_count, - error_count, retry_rounds, "prefetch completed with failures after retries" - ); - } else if retry_rounds > 0 { - info!( - success_count, - retry_rounds, "prefetch completed — all failures recovered via retries" - ); - } - - AdaptivePrefetchResult { - success_count, - error_count, - retry_rounds, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Set speed to Fast so tests use original (unmultiplied) defaults. - fn set_fast_mode() { - crate::tuning::set_sync_speed_mode(SyncSpeedMode::Fast); - } - - #[test] - fn test_adjust_throttles_on_high_failure_rate() { - set_fast_mode(); - let config = AdaptivePrefetchConfig::default(); - let mut state = AdaptiveState::new(config); - assert_eq!(state.current_chunk_size, 30); - assert_eq!(state.current_delay, Duration::from_millis(15)); - - // >25% failure rate: 15 out of 30 - state.adjust_after_chunk(30, 15); - assert_eq!(state.current_chunk_size, 15); - assert_eq!(state.current_delay, Duration::from_millis(30)); - - // >25% again: 8 out of 15 - state.adjust_after_chunk(15, 8); - assert_eq!(state.current_chunk_size, 7); - assert_eq!(state.current_delay, Duration::from_millis(60)); - } - - #[test] - fn test_adjust_recovers_on_zero_failures() { - set_fast_mode(); - let config = AdaptivePrefetchConfig::default(); - let mut state = AdaptiveState::new(config); - - // Throttle down first - state.adjust_after_chunk(30, 15); - assert_eq!(state.current_chunk_size, 15); - assert_eq!(state.current_delay, Duration::from_millis(30)); - - // Recover with zero failures - state.adjust_after_chunk(15, 0); - assert_eq!(state.current_chunk_size, 19); - assert_eq!(state.current_delay, Duration::from_millis(15)); - } - - #[test] - fn test_adjust_holds_steady_below_threshold() { - set_fast_mode(); - let config = AdaptivePrefetchConfig::default(); - let mut state = AdaptiveState::new(config); - - // Exactly 25% failure rate (at threshold, not above) - state.adjust_after_chunk(40, 10); - assert_eq!(state.current_chunk_size, 30); - assert_eq!(state.current_delay, Duration::from_millis(15)); - } - - #[test] - fn test_chunk_size_respects_min() { - let config = AdaptivePrefetchConfig { - initial_chunk_size: 10, - min_chunk_size: 5, - ..Default::default() - }; - let mut state = AdaptiveState::new(config); - - // Throttle: 10 -> 5 - state.adjust_after_chunk(10, 10); - assert_eq!(state.current_chunk_size, 5); - - // Throttle again: stays at 5 (min) - state.adjust_after_chunk(5, 5); - assert_eq!(state.current_chunk_size, 5); - } - - #[test] - fn test_delay_respects_max() { - let config = AdaptivePrefetchConfig { - max_delay: Duration::from_millis(100), - ..Default::default() - }; - let mut state = AdaptiveState::new(config); - - // Throttle repeatedly - state.adjust_after_chunk(40, 40); // 10ms -> 20ms - state.adjust_after_chunk(20, 20); // 20ms -> 40ms - state.adjust_after_chunk(10, 10); // 40ms -> 80ms - state.adjust_after_chunk(5, 5); // 80ms -> capped at 100ms - assert_eq!(state.current_delay, Duration::from_millis(100)); - - // One more — still at max - state.adjust_after_chunk(5, 5); - assert_eq!(state.current_delay, Duration::from_millis(100)); - } - - #[test] - fn test_chunk_size_respects_max_on_recovery() { - set_fast_mode(); - let config = AdaptivePrefetchConfig::default(); - let mut state = AdaptiveState::new(config); - - // Already at max — recovery doesn't go above - state.adjust_after_chunk(30, 0); - assert_eq!(state.current_chunk_size, 30); - } - - #[test] - fn test_empty_chunk_no_change() { - set_fast_mode(); - let config = AdaptivePrefetchConfig::default(); - let mut state = AdaptiveState::new(config); - - state.adjust_after_chunk(0, 0); - assert_eq!(state.current_chunk_size, 30); - assert_eq!(state.current_delay, Duration::from_millis(15)); - } - - #[test] - fn test_throttle_aware_no_backoff() { - // With no domain throttling active, throttle_aware should match default - let config = AdaptivePrefetchConfig::throttle_aware(); - let default = AdaptivePrefetchConfig::default(); - // When no domains are throttled, chunk size should be at or near default - // (exact match depends on global test state, so just verify bounds) - assert!(config.initial_chunk_size >= THROTTLED_MIN_CHUNK_SIZE); - assert!(config.initial_chunk_size <= default.initial_chunk_size); - } - - #[test] - fn test_throttle_aware_with_active_backoff() { - use alloy_transport_balancer::{ - domain_throttle, record_rate_limit, weighted_domain_backoff, - }; - - // Simulate domain throttling by recording rate limits - let state = domain_throttle("test-prefetch-aware.com"); - record_rate_limit(&state); - record_rate_limit(&state); - - // weighted_domain_backoff should be non-zero with a throttled domain - let delay = weighted_domain_backoff(); - assert!( - delay.as_millis() > 0, - "expected some backoff after rate limits" - ); - - let config = AdaptivePrefetchConfig::throttle_aware(); - let default = AdaptivePrefetchConfig::default(); - - // With weighted backoff, the chunk size should be reduced (exact amount - // depends on other domains in the global registry from parallel tests) - assert!(config.initial_chunk_size >= THROTTLED_MIN_CHUNK_SIZE); - assert!(config.initial_chunk_size <= default.initial_chunk_size); - } - - #[test] - fn test_throttle_aware_severe_backoff() { - use alloy_transport_balancer::{domain_throttle, record_rate_limit}; - - // Push to level 4 (1000ms delay) - let state = domain_throttle("test-prefetch-severe.com"); - for _ in 0..4 { - record_rate_limit(&state); - } - - let config = AdaptivePrefetchConfig::throttle_aware(); - let default = AdaptivePrefetchConfig::default(); - - // With severe throttling, chunk size should be reduced - // (exact reduction depends on weighted average across all test domains) - assert!(config.initial_chunk_size >= THROTTLED_MIN_CHUNK_SIZE); - assert!(config.initial_chunk_size <= default.initial_chunk_size); - } -} diff --git a/src/cache_sync/balancer_sync.rs b/src/cache_sync/balancer_sync.rs deleted file mode 100644 index dca7eeb..0000000 --- a/src/cache_sync/balancer_sync.rs +++ /dev/null @@ -1,206 +0,0 @@ -use super::*; - -/// Initialize a Balancer pool from the EVM cache. -/// -/// **Important**: The caller must purge the vault's storage once before calling this -/// function in a batch. The vault is a shared contract across all Balancer pools, so -/// purging it per-pool wastes RPC calls. Use `cache.purge_pool_storage(vault)` once -/// before the init loop. -#[instrument(skip(cache), fields(pool_id = %pool_id, vault = %vault))] -pub async fn init_balancer_from_cache( - cache: &mut EvmCache, - pool_id: alloy_primitives::B256, - vault: Address, -) -> Result { - let fn_start = Instant::now(); - - let t0 = Instant::now(); - let pool_addr = BalancerPool::address(pool_id); - cache.ensure_account(pool_addr).await?; - cache.ensure_account(vault).await?; - let ensure_ms = t0.elapsed().as_millis(); - - // Try to load immutable metadata from cache (tokens, weights, swap_fee) - let (tokens_vec, weights_vec, swap_fee, cached_last_change_block) = - if let Some(metadata) = cache.immutable_cache().get_balancer_pool(pool_id) { - debug!("using cached Balancer pool metadata"); - ( - metadata.tokens, - metadata.weights, - metadata.swap_fee, - Some(metadata.last_change_block), - ) - } else { - // No cached metadata - will fetch fresh below - (Vec::new(), Vec::new(), U256::ZERO, None) - }; - - let has_cached_metadata = !tokens_vec.is_empty(); - - // Only purge the pool contract storage when we don't have cached metadata. - // When metadata is cached, we don't read from the pool contract at all (weights - // and swap_fee come from cache), so purging it is unnecessary. - if !has_cached_metadata && cache.has_pool_storage(pool_addr) { - let purged = cache.purge_pool_storage(pool_addr); - debug!( - pool_id = %pool_id, - pool_addr = %pool_addr, - purged_slots = purged, - "purged Balancer pool storage (no cached metadata)" - ); - } - - // Fetch fresh pool tokens data from RPC. - // The caller is responsible for purging the vault's storage once before the - // init loop so that getPoolTokens reads fresh balances from RPC. - let tokens_result = call_view( - cache, - vault, - IBalancerVault::getPoolTokensCall { poolId: pool_id }, - )?; - let fresh_last_change_block = tokens_result.lastChangeBlock; - - // Log if the lastChangeBlock changed (indicates balances were stale) - if let Some(cached_block) = cached_last_change_block - && cached_block != fresh_last_change_block - { - debug!( - pool_id = %pool_id, - cached_block = %cached_block, - fresh_block = %fresh_last_change_block, - "Balancer pool balances were stale (lastChangeBlock changed)" - ); - } - - // Determine tokens, weights, and swap_fee - let (final_tokens, final_weights, final_swap_fee) = if has_cached_metadata { - // Use cached immutable data - (tokens_vec, weights_vec, swap_fee) - } else { - // Fetch immutable data from RPC - let weights = call_view(cache, pool_addr, IBalancerPool::getNormalizedWeightsCall {})?; - let swap_fee = call_view(cache, pool_addr, IBalancerPool::getSwapFeePercentageCall {})?; - debug!("fetched Balancer pool immutable metadata from RPC"); - (tokens_result.tokens.clone(), weights.clone(), swap_fee) - }; - - // Update immutable cache with fresh lastChangeBlock for future validation - cache.immutable_cache_mut().set_balancer_pool( - pool_id, - BalancerPoolMetadata { - tokens: final_tokens.clone(), - weights: final_weights.clone(), - swap_fee: final_swap_fee, - last_change_block: fresh_last_change_block, - }, - ); - - // Use tokens/weights/swap_fee with fresh balances - let params = PoolParams::new_from_parts( - final_tokens, - tokens_result.balances, - final_weights, - final_swap_fee, - ); - - let mut decimals = HashMap::with_capacity(params.tokens().len()); - for token in params.tokens() { - let dec = cache.erc20_decimals(token).unwrap_or(18); - decimals.insert(token, dec); - } - - let inner = WeightedPool::from_params(¶ms, &decimals); - - let total_ms = fn_start.elapsed().as_millis(); - debug!( - pool_id = %pool_id, - ensure_ms, - total_ms, - "Balancer pool init breakdown" - ); - - Ok(BalancerPool::from_weights(pool_id, vault, inner, decimals)) -} - -/// Refresh the balances of a Balancer pool for per-cycle freshness. -/// -/// Purges vault storage for getPoolTokens, re-fetches fresh balances, -/// and rebuilds the pool's WeightedPool with updated balances while -/// keeping immutable tokens, weights, and swap_fee from cache. -/// -/// **Important**: The caller should purge the vault's storage once before calling -/// this in a batch (the vault is shared across Balancer pools). -#[instrument(skip(cache), fields(pool_id = %pool.pool_id))] -pub fn refresh_balancer_pool(cache: &mut EvmCache, pool: &mut BalancerPool) -> Result<()> { - let fn_start = Instant::now(); - - // Load immutable metadata from cache (tokens, weights, swap_fee) - let metadata = cache - .immutable_cache() - .get_balancer_pool(pool.pool_id) - .ok_or_else(|| anyhow!("no cached metadata for Balancer pool {}", pool.pool_id))?; - - // Fetch fresh pool tokens data from vault (vault storage should already be purged by caller) - let tokens_result = call_view( - cache, - pool.vault, - IBalancerVault::getPoolTokensCall { - poolId: pool.pool_id, - }, - )?; - - // Build PoolParams with fresh balances + cached immutable data - let params = PoolParams::new_from_parts( - metadata.tokens.clone(), - tokens_result.balances, - metadata.weights.clone(), - metadata.swap_fee, - ); - - // Get decimals for each token - let mut decimals = HashMap::with_capacity(metadata.tokens.len()); - for &token in &metadata.tokens { - let dec = cache.erc20_decimals(token).unwrap_or(18); - decimals.insert(token, dec); - } - - // Log balance changes for diagnostics - let old_balances = pool.balances_u256(); - pool.refresh_from_params(¶ms, &decimals); - let new_balances = pool.balances_u256(); - - for ((token, old_bal), (_, new_bal)) in old_balances.iter().zip(new_balances.iter()) { - if old_bal != new_bal { - debug!( - token = %token, - old_balance = %old_bal, - new_balance = %new_bal, - "Balancer pool balance changed" - ); - } - } - - // Update immutable cache with fresh lastChangeBlock - let fresh_last_change_block = tokens_result.lastChangeBlock; - if metadata.last_change_block != fresh_last_change_block { - debug!( - cached_block = %metadata.last_change_block, - fresh_block = %fresh_last_change_block, - "Balancer pool lastChangeBlock changed" - ); - } - cache.immutable_cache_mut().set_balancer_pool( - pool.pool_id, - BalancerPoolMetadata { - tokens: metadata.tokens, - weights: metadata.weights, - swap_fee: metadata.swap_fee, - last_change_block: fresh_last_change_block, - }, - ); - - let total_ms = fn_start.elapsed().as_millis(); - debug!(total_ms, "Balancer pool refreshed"); - - Ok(()) -} diff --git a/src/cache_sync/balancer_v3_sync.rs b/src/cache_sync/balancer_v3_sync.rs deleted file mode 100644 index 2c4c547..0000000 --- a/src/cache_sync/balancer_v3_sync.rs +++ /dev/null @@ -1,279 +0,0 @@ -use super::*; -use crate::balancer_v3_pool::{BalancerV3Pool, BalancerV3PoolType}; - -// Balancer V3 vault ABI — different from V2 (uses address, not bytes32 poolId). -sol!( - #[sol(rpc)] - contract IBalancerV3Vault { - function getCurrentLiveBalances(address pool) external view returns (uint256[] memory balancesLiveScaled18); - function getStaticSwapFeePercentage(address pool) external view returns (uint256); - } -); - -sol!( - #[sol(rpc)] - contract IBalancerV3Pool { - function getNormalizedWeights() external view returns (uint256[] memory); - } -); - -sol!( - #[sol(rpc)] - contract IBalancerV3StablePool { - function getAmplificationParameter() external view returns (uint256 value, bool isUpdating, uint256 precision); - } -); - -/// Initialize a Balancer V3 pool from the EVM cache. -/// -/// Fetches live balances from the V3 vault, weights and swap fee from the pool -/// contract. Live balances are returned in 18-decimal scaled format and must be -/// downscaled to native token decimals. -/// -/// **Important**: The caller should purge the vault's storage once before calling -/// this in a batch — the vault is shared across pools. -/// `pool_type_hint`: optional config-driven hint. When provided, skips the trial -/// `getNormalizedWeights()` call and directly fetches the appropriate parameters. -/// Falls back to auto-detection if `None`. -#[instrument(skip(cache, pool_type_hint), fields(pool = %address, vault = %vault))] -pub async fn init_balancer_v3_from_cache( - cache: &mut EvmCache, - address: Address, - vault: Address, - token_a: Address, - token_b: Address, - pool_type_hint: Option, -) -> Result { - cache.ensure_account(address).await?; - cache.ensure_account(vault).await?; - - // Fetch live balances (always 18-decimal scaled in V3) - let live_balances: Vec = call_view( - cache, - vault, - IBalancerV3Vault::getCurrentLiveBalancesCall { pool: address }, - )?; - - if live_balances.len() < 2 { - return Err(anyhow!( - "BalancerV3 pool {} returned {} balances, expected >= 2", - address, - live_balances.len() - )); - } - - // Fetch swap fee percentage (1e18 fixed point) - let swap_fee: U256 = call_view( - cache, - vault, - IBalancerV3Vault::getStaticSwapFeePercentageCall { pool: address }, - )?; - - // Detect pool type — use config hint when available, otherwise auto-detect. - let (pool_type, weights, amplification_factor) = match pool_type_hint { - Some(BalancerV3PoolType::Stable) => { - // Config says stable — go straight to fetching A parameter - fetch_stable_params(cache, address)? - } - Some(BalancerV3PoolType::Weighted) => { - // Config says weighted — go straight to fetching weights - fetch_weighted_params(cache, address)? - } - None => { - // Auto-detect: try weights first, fall back to stable - match call_view(cache, address, IBalancerV3Pool::getNormalizedWeightsCall {}) { - Ok(w) if w.len() >= 2 => (BalancerV3PoolType::Weighted, [w[0], w[1]], None), - _ => fetch_stable_params(cache, address)?, - } - } - }; - - // Get token decimals for downscaling live balances - let dec_a = cache.erc20_decimals(token_a).unwrap_or(18); - let dec_b = cache.erc20_decimals(token_b).unwrap_or(18); - - // Downscale from 18 decimals to native token decimals - let balance_a = downscale_18(live_balances[0], dec_a); - let balance_b = downscale_18(live_balances[1], dec_b); - - debug!( - pool = %address, - balance_a = %balance_a, - balance_b = %balance_b, - weight_a = %weights[0], - weight_b = %weights[1], - pool_type = ?pool_type, - swap_fee = %swap_fee, - dec_a, - dec_b, - "BalancerV3 pool initialized" - ); - - Ok(BalancerV3Pool { - address, - vault, - token_a, - token_b, - balances: [balance_a, balance_b], - weights: [weights[0], weights[1]], - swap_fee, - decimals: [dec_a, dec_b], - pool_type, - amplification_factor, - }) -} - -/// Fetch weights for a known-weighted Balancer V3 pool. -fn fetch_weighted_params( - cache: &mut EvmCache, - address: Address, -) -> Result<(BalancerV3PoolType, [U256; 2], Option)> { - match call_view(cache, address, IBalancerV3Pool::getNormalizedWeightsCall {}) { - Ok(w) if w.len() >= 2 => Ok((BalancerV3PoolType::Weighted, [w[0], w[1]], None)), - _ => { - let half = U256::from(500_000_000_000_000_000u64); - debug!(pool = %address, "Weighted pool hint but getNormalizedWeights failed, using 50/50"); - Ok((BalancerV3PoolType::Weighted, [half, half], None)) - } - } -} - -/// Fetch amplification parameter for a known-stable (or auto-detected stable) Balancer V3 pool. -fn fetch_stable_params( - cache: &mut EvmCache, - address: Address, -) -> Result<(BalancerV3PoolType, [U256; 2], Option)> { - let half = U256::from(500_000_000_000_000_000u64); // 0.5e18 - match call_view( - cache, - address, - IBalancerV3StablePool::getAmplificationParameterCall {}, - ) { - Ok(ret) if !ret.precision.is_zero() => { - let (value, precision) = (ret.value, ret.precision); - // Balancer: value = A * precision (precision=1000) - // Curve expects: _A() = A * 100 (A_PRECISION=100) - let curve_a = value * U256::from(100) / precision; - debug!( - pool = %address, - balancer_a = %value, - precision = %precision, - curve_a = %curve_a, - "Stable pool: fetched amplification parameter" - ); - Ok((BalancerV3PoolType::Stable, [half, half], Some(curve_a))) - } - _ => { - // Couldn't fetch A — fall back to weighted with 50/50 - debug!(pool = %address, "No weights or A parameter, falling back to weighted 50/50"); - Ok((BalancerV3PoolType::Weighted, [half, half], None)) - } - } -} - -/// Refresh the balances of a Balancer V3 pool for per-cycle freshness. -/// -/// Re-fetches live balances from the vault and downscales to native decimals. -/// Weights and swap fee are immutable for V3 pools. -/// -/// **Important**: The caller should purge the vault's storage once before calling -/// this in a batch. -#[instrument(skip(cache), fields(pool = %pool.address))] -pub fn refresh_balancer_v3_pool(cache: &mut EvmCache, pool: &mut BalancerV3Pool) -> Result<()> { - let live_balances: Vec = call_view( - cache, - pool.vault, - IBalancerV3Vault::getCurrentLiveBalancesCall { pool: pool.address }, - )?; - - if live_balances.len() < 2 { - return Err(anyhow!( - "BalancerV3 pool {} returned {} balances on refresh", - pool.address, - live_balances.len() - )); - } - - let new_a = downscale_18(live_balances[0], pool.decimals[0]); - let new_b = downscale_18(live_balances[1], pool.decimals[1]); - - if pool.balances[0] != new_a || pool.balances[1] != new_b { - debug!( - pool = %pool.address, - old_a = %pool.balances[0], - new_a = %new_a, - old_b = %pool.balances[1], - new_b = %new_b, - "BalancerV3 pool balances changed" - ); - } - - pool.balances[0] = new_a; - pool.balances[1] = new_b; - - Ok(()) -} - -/// Downscale a value from 18 decimals to the given native decimal count. -fn downscale_18(value: U256, decimals: u8) -> U256 { - if decimals >= 18 { - value * U256::from(10u64).pow(U256::from(decimals - 18)) - } else { - value / U256::from(10u64).pow(U256::from(18 - decimals)) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn downscale_18_to_6_decimals() { - // 1.0 token in 18 decimals → 1_000_000 in 6 decimals (USDC) - let value = U256::from(1_000_000_000_000_000_000u128); - assert_eq!(downscale_18(value, 6), U256::from(1_000_000u64)); - } - - #[test] - fn downscale_18_to_8_decimals() { - // 1.0 token in 18 decimals → 100_000_000 in 8 decimals (WBTC) - let value = U256::from(1_000_000_000_000_000_000u128); - assert_eq!(downscale_18(value, 8), U256::from(100_000_000u64)); - } - - #[test] - fn downscale_18_noop_at_18_decimals() { - let value = U256::from(1_000_000_000_000_000_000u128); - assert_eq!(downscale_18(value, 18), value); - } - - #[test] - fn downscale_18_zero_value() { - assert_eq!(downscale_18(U256::ZERO, 6), U256::ZERO); - assert_eq!(downscale_18(U256::ZERO, 18), U256::ZERO); - } - - #[test] - fn downscale_18_to_0_decimals() { - // 1.0 token in 18 decimals → 1 in 0 decimals - let value = U256::from(1_000_000_000_000_000_000u128); - assert_eq!(downscale_18(value, 0), U256::from(1u64)); - } - - #[test] - fn downscale_18_fractional_truncates() { - // 0.5 USDC worth in 18 decimals → should truncate to 500000 - let value = U256::from(500_000_000_000_000_000u128); - assert_eq!(downscale_18(value, 6), U256::from(500_000u64)); - } - - #[test] - fn downscale_18_above_18_decimals_upscales() { - // Edge case: if a token had 20 decimals, should multiply by 100 - let value = U256::from(1_000_000_000_000_000_000u128); - assert_eq!( - downscale_18(value, 20), - U256::from(100_000_000_000_000_000_000u128) - ); - } -} diff --git a/src/cache_sync/compat.rs b/src/cache_sync/compat.rs deleted file mode 100644 index 394c93c..0000000 --- a/src/cache_sync/compat.rs +++ /dev/null @@ -1,371 +0,0 @@ -//! Temporary compatibility layer for legacy cache-sync code. -//! -//! The new `evm-fork-cache` release intentionally removed protocol-specific -//! metadata and V3 tick snapshot storage from `EvmCache`. The legacy -//! `cache_sync` modules still expect those helpers while we migrate protocol -//! ownership into this crate. This module restores the old call surface over a -//! process-local sidecar store so the existing paths keep compiling. Treat this -//! as transitional glue, not the final persistence model for AMM metadata. - -use std::collections::HashMap; -use std::sync::{OnceLock, RwLock, RwLockReadGuard, RwLockWriteGuard}; - -use alloy_primitives::{Address, B256, U256}; -use anyhow::Result; -use evm_fork_cache::cache::{EvmCache, ImmutableDataCache}; -use evm_fork_cache::{PurgeScope, StateUpdate}; -use serde::{Deserialize, Serialize}; - -use crate::adapters::storage::{ - v3_tick_bitmap_storage_key_with_base, v3_tick_info_storage_keys_with_base, -}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub(crate) struct V2PoolMetadata { - pub token0: Address, - pub token1: Address, - pub last_block_timestamp: u32, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub(crate) struct V3PoolMetadata { - pub token0: Address, - pub token1: Address, - pub fee: u32, - pub tick_spacing: i32, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub(crate) struct BalancerPoolMetadata { - pub tokens: Vec
, - pub weights: Vec, - pub swap_fee: U256, - pub last_change_block: U256, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub(crate) struct TickInfo { - pub liquidity_gross: u128, - pub liquidity_net: i128, - pub initialized: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub(crate) struct SerializableTickInfo { - pub liquidity_gross: u128, - pub liquidity_net: i128, - pub initialized: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub(crate) struct V3PoolTickSnapshot { - pub tick_bitmap: HashMap, - pub ticks: HashMap, - pub last_liquidity: u128, - pub last_tick: i32, -} - -impl V3PoolTickSnapshot { - pub fn from_pool_data( - tick_bitmap: &HashMap, - ticks: &HashMap, - liquidity: u128, - tick: i32, - ) -> Self { - Self { - tick_bitmap: tick_bitmap - .iter() - .map(|(key, value)| (key.to_string(), *value)) - .collect(), - ticks: ticks - .iter() - .map(|(key, value)| { - ( - key.to_string(), - SerializableTickInfo { - liquidity_gross: value.liquidity_gross, - liquidity_net: value.liquidity_net, - initialized: value.initialized, - }, - ) - }) - .collect(), - last_liquidity: liquidity, - last_tick: tick, - } - } - - pub fn to_tick_bitmap(&self) -> HashMap { - self.tick_bitmap - .iter() - .filter_map(|(key, value)| key.parse::().ok().map(|key| (key, *value))) - .collect() - } - - pub fn to_ticks(&self) -> HashMap { - self.ticks - .iter() - .filter_map(|(key, value)| { - key.parse::().ok().map(|key| { - ( - key, - TickInfo { - liquidity_gross: value.liquidity_gross, - liquidity_net: value.liquidity_net, - initialized: value.initialized, - }, - ) - }) - }) - .collect() - } -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub(crate) struct V3TickSnapshotCache { - snapshots: HashMap, -} - -impl V3TickSnapshotCache { - fn get(&self, address: Address) -> Option { - self.snapshots.get(&address).cloned() - } - - fn set(&mut self, address: Address, snapshot: V3PoolTickSnapshot) { - self.snapshots.insert(address, snapshot); - } -} - -pub(crate) struct TickSnapshotCacheReadGuard(RwLockReadGuard<'static, V3TickSnapshotCache>); - -impl TickSnapshotCacheReadGuard { - pub(crate) fn get(&self, address: Address) -> Option { - self.0.get(address) - } -} - -pub(crate) struct TickSnapshotCacheWriteGuard(RwLockWriteGuard<'static, V3TickSnapshotCache>); - -impl TickSnapshotCacheWriteGuard { - pub(crate) fn set(&mut self, address: Address, snapshot: V3PoolTickSnapshot) { - self.0.set(address, snapshot); - } -} - -#[derive(Default)] -struct ProtocolMetadataStore { - v2_pools: HashMap, - v3_pools: HashMap, - balancer_pools: HashMap, -} - -static PROTOCOL_METADATA: OnceLock> = OnceLock::new(); -static TICK_SNAPSHOTS: OnceLock> = OnceLock::new(); - -fn protocol_metadata() -> &'static RwLock { - PROTOCOL_METADATA.get_or_init(|| RwLock::new(ProtocolMetadataStore::default())) -} - -fn tick_snapshots() -> &'static RwLock { - TICK_SNAPSHOTS.get_or_init(|| RwLock::new(V3TickSnapshotCache::default())) -} - -pub(crate) trait ImmutableDataCacheProtocolExt { - fn get_v2_pool(&self, address: Address) -> Option; - fn set_v2_pool(&mut self, address: Address, metadata: V2PoolMetadata); - fn get_v3_pool(&self, address: Address) -> Option; - fn set_v3_pool(&mut self, address: Address, metadata: V3PoolMetadata); - fn get_balancer_pool(&self, pool_id: B256) -> Option; - fn set_balancer_pool(&mut self, pool_id: B256, metadata: BalancerPoolMetadata); -} - -impl ImmutableDataCacheProtocolExt for ImmutableDataCache { - fn get_v2_pool(&self, address: Address) -> Option { - protocol_metadata() - .read() - .ok()? - .v2_pools - .get(&address) - .cloned() - } - - fn set_v2_pool(&mut self, address: Address, metadata: V2PoolMetadata) { - if let Ok(mut store) = protocol_metadata().write() { - store.v2_pools.insert(address, metadata); - } - } - - fn get_v3_pool(&self, address: Address) -> Option { - protocol_metadata() - .read() - .ok()? - .v3_pools - .get(&address) - .cloned() - } - - fn set_v3_pool(&mut self, address: Address, metadata: V3PoolMetadata) { - if let Ok(mut store) = protocol_metadata().write() { - store.v3_pools.insert(address, metadata); - } - } - - fn get_balancer_pool(&self, pool_id: B256) -> Option { - protocol_metadata() - .read() - .ok()? - .balancer_pools - .get(&pool_id) - .cloned() - } - - fn set_balancer_pool(&mut self, pool_id: B256, metadata: BalancerPoolMetadata) { - if let Ok(mut store) = protocol_metadata().write() { - store.balancer_pools.insert(pool_id, metadata); - } - } -} - -pub(crate) trait EvmCacheProtocolExt { - fn has_pool_storage(&self, address: Address) -> bool; - fn pool_storage_slot_count(&self, address: Address) -> usize; - fn purge_pool_storage(&mut self, address: Address) -> usize; - fn purge_pool_slots(&mut self, address: Address, slots: &[U256]) -> usize; - fn inject_v2_pool_metadata( - &mut self, - pool_address: Address, - metadata: &V2PoolMetadata, - ) -> Result<()>; - fn inject_v3_tick_bitmap_with_base( - &mut self, - pool_address: Address, - tick_bitmap: &HashMap, - base_slot: U256, - ) -> Result; - fn inject_v3_ticks_with_base( - &mut self, - pool_address: Address, - ticks: &HashMap, - ticks_slot: U256, - ) -> Result; - fn tick_snapshot_cache(&self) -> TickSnapshotCacheReadGuard; - fn tick_snapshot_cache_mut(&mut self) -> TickSnapshotCacheWriteGuard; -} - -impl EvmCacheProtocolExt for EvmCache { - fn has_pool_storage(&self, address: Address) -> bool { - self.has_contract_storage(address) - } - - fn pool_storage_slot_count(&self, address: Address) -> usize { - self.contract_storage_slot_count(address) - } - - fn purge_pool_storage(&mut self, address: Address) -> usize { - self.apply_update(&StateUpdate::purge(address, PurgeScope::AllStorage)) - .purged - .first() - .map(|record| record.slots_removed) - .unwrap_or(0) - } - - fn purge_pool_slots(&mut self, address: Address, slots: &[U256]) -> usize { - self.apply_update(&StateUpdate::purge( - address, - PurgeScope::Slots(slots.to_vec()), - )) - .purged - .first() - .map(|record| record.slots_removed) - .unwrap_or(0) - } - - fn inject_v2_pool_metadata( - &mut self, - pool_address: Address, - metadata: &V2PoolMetadata, - ) -> Result<()> { - const TOKEN0_SLOT: U256 = U256::from_limbs([6, 0, 0, 0]); - const TOKEN1_SLOT: U256 = U256::from_limbs([7, 0, 0, 0]); - - self.apply_updates(&[ - StateUpdate::slot( - pool_address, - TOKEN0_SLOT, - U256::from_be_slice(metadata.token0.as_slice()), - ), - StateUpdate::slot( - pool_address, - TOKEN1_SLOT, - U256::from_be_slice(metadata.token1.as_slice()), - ), - ]); - Ok(()) - } - - fn inject_v3_tick_bitmap_with_base( - &mut self, - pool_address: Address, - tick_bitmap: &HashMap, - base_slot: U256, - ) -> Result { - let updates: Vec<_> = tick_bitmap - .iter() - .map(|(&word_position, &bitmap)| { - StateUpdate::slot( - pool_address, - v3_tick_bitmap_storage_key_with_base(word_position, base_slot), - bitmap, - ) - }) - .collect(); - let injected = updates.len(); - self.apply_updates(&updates); - Ok(injected) - } - - fn inject_v3_ticks_with_base( - &mut self, - pool_address: Address, - ticks: &HashMap, - ticks_slot: U256, - ) -> Result { - let mut updates = Vec::with_capacity(ticks.len() * 2); - for (&tick, info) in ticks { - let keys = v3_tick_info_storage_keys_with_base(tick, ticks_slot); - let packed_liquidity = - U256::from(info.liquidity_gross) | (i128_to_u256(info.liquidity_net) << 128); - let initialized = if info.initialized { - U256::from(1u64) << 248 - } else { - U256::ZERO - }; - updates.push(StateUpdate::slot(pool_address, keys[0], packed_liquidity)); - updates.push(StateUpdate::slot(pool_address, keys[3], initialized)); - } - - let injected = ticks.len(); - self.apply_updates(&updates); - Ok(injected) - } - - fn tick_snapshot_cache(&self) -> TickSnapshotCacheReadGuard { - TickSnapshotCacheReadGuard( - tick_snapshots() - .read() - .expect("V3 tick snapshot cache lock poisoned"), - ) - } - - fn tick_snapshot_cache_mut(&mut self) -> TickSnapshotCacheWriteGuard { - TickSnapshotCacheWriteGuard( - tick_snapshots() - .write() - .expect("V3 tick snapshot cache lock poisoned"), - ) - } -} - -fn i128_to_u256(value: i128) -> U256 { - U256::from(value as u128) -} diff --git a/src/cache_sync/curve_sync.rs b/src/cache_sync/curve_sync.rs deleted file mode 100644 index 051bda0..0000000 --- a/src/cache_sync/curve_sync.rs +++ /dev/null @@ -1,155 +0,0 @@ -use super::*; -use crate::curve_pool::CurvePool; - -sol!( - #[sol(rpc)] - contract ICurvePool { - function balances(uint256 index) external view returns (uint256); - function A() external view returns (uint256); - function fee() external view returns (uint256); - } -); - -sol!( - #[sol(rpc)] - contract ICurveCryptoPool { - function gamma() external view returns (uint256); - function price_scale() external view returns (uint256); - function mid_fee() external view returns (uint256); - function out_fee() external view returns (uint256); - } -); - -sol!( - #[sol(rpc)] - contract ICurveCryptoPoolIndexed { - function price_scale(uint256 k) external view returns (uint256); - } -); - -/// Initialize a Curve pool from the EVM cache. -/// -/// Fetches per-coin reserves via `balances(i)`, amplification parameter `A()`, -/// and `fee()`. Computes precision multipliers from token decimals. -#[instrument(skip(cache), fields(pool = %address, n_coins = tokens.len()))] -pub async fn init_curve_from_cache( - cache: &mut EvmCache, - address: Address, - tokens: &[Address], - use_uint256: bool, -) -> Result { - cache.ensure_account(address).await?; - - let n = tokens.len(); - - // Fetch per-coin reserves - let mut reserves = Vec::with_capacity(n); - for i in 0..n { - let bal: U256 = call_view( - cache, - address, - ICurvePool::balancesCall { - index: U256::from(i), - }, - )?; - reserves.push(bal); - } - - // Fetch amplification parameter — Curve's external A() returns the base value; - // internally _A() = A() * A_PRECISION (100). Our simulation expects the internal form. - let a_external: U256 = call_view(cache, address, ICurvePool::ACall {})?; - let a = a_external * U256::from(100); - - // Fetch fee (parts-per-1e10) - let fee: U256 = call_view(cache, address, ICurvePool::feeCall {})?; - - // Compute precision multipliers: 10^(18 - decimals[i]) - let mut precision_multipliers = Vec::with_capacity(n); - for &token in tokens { - let dec = cache.erc20_decimals(token).unwrap_or(18); - let pm = if dec <= 18 { - U256::from(10u64).pow(U256::from(18 - dec)) - } else { - U256::from(1) // Should not happen, but protect against > 18 decimals - }; - precision_multipliers.push(pm); - } - - // For cryptoswap pools, fetch gamma, price_scale, and fee range - let (gamma, price_scale, fee_out, effective_fee) = if use_uint256 { - let gamma: U256 = call_view(cache, address, ICurveCryptoPool::gammaCall {})?; - - // Fetch price_scale: for 2-coin pools use no-arg version, for 3+ use indexed version - let price_scale = if n > 2 { - let mut scales = Vec::with_capacity(n - 1); - for k in 0..(n - 1) { - let ps: U256 = call_view( - cache, - address, - ICurveCryptoPoolIndexed::price_scaleCall { k: U256::from(k) }, - )?; - scales.push(ps); - } - scales - } else { - let ps: U256 = call_view(cache, address, ICurveCryptoPool::price_scaleCall {})?; - vec![ps] - }; - - // Try to read mid_fee/out_fee; fall back to single fee() if unavailable - let mid_fee: U256 = - call_view(cache, address, ICurveCryptoPool::mid_feeCall {}).unwrap_or(fee); - let out_fee: U256 = - call_view(cache, address, ICurveCryptoPool::out_feeCall {}).unwrap_or(fee); - - (Some(gamma), price_scale, Some(out_fee), mid_fee) - } else { - (None, vec![], None, fee) - }; - - debug!( - pool = %address, - n_coins = n, - use_uint256, - a = %a, - fee = %effective_fee, - ?gamma, - reserves = ?reserves.iter().map(|r| format!("{}", r)).collect::>(), - "Curve pool initialized" - ); - - Ok(CurvePool { - address, - tokens: tokens.to_vec(), - use_uint256, - reserves, - a, - fee: effective_fee, - precision_multipliers, - gamma, - price_scale, - fee_out, - }) -} - -/// Refresh the reserves of a Curve pool for per-cycle freshness. -/// -/// Re-fetches `balances(i)` for each coin. A and fee are considered stable -/// enough to not refresh every cycle (they change very rarely via governance). -#[instrument(skip(cache), fields(pool = %pool.address))] -pub fn refresh_curve_reserves(cache: &mut EvmCache, pool: &mut CurvePool) -> Result<()> { - cache.purge_pool_storage(pool.address); - - for i in 0..pool.tokens.len() { - let bal: U256 = call_view( - cache, - pool.address, - ICurvePool::balancesCall { - index: U256::from(i), - }, - )?; - pool.reserves[i] = bal; - } - - Ok(()) -} diff --git a/src/cache_sync/decode.rs b/src/cache_sync/decode.rs deleted file mode 100644 index f950bee..0000000 --- a/src/cache_sync/decode.rs +++ /dev/null @@ -1,148 +0,0 @@ -use std::time::{SystemTime, UNIX_EPOCH}; - -use alloy_primitives::U256; -use amms::amms::uniswap_v3::Info; - -/// Pack V2 reserves into slot 8 format for EVM cache injection. -/// -/// V2 slot 8 packing: `reserve0(uint112) | reserve1(uint112) << 112 | blockTimestampLast(uint32) << 224` -pub fn encode_v2_reserves_raw(reserve0: u128, reserve1: u128) -> U256 { - let timestamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs() as u32; - U256::from(reserve0) | (U256::from(reserve1) << 112) | (U256::from(timestamp) << 224) -} - -/// Patch sqrtPriceX96 + tick into an existing V3 slot0 value, preserving observation fields. -/// -/// V3 slot 0 packing: `sqrtPriceX96(uint160) | tick(int24) << 160 | observationIndex(uint16) << 184 | ...` -/// This patches bits 0-183 while preserving bits 184-255 (observation metadata, feeProtocol, unlocked). -pub fn encode_v3_slot0_patch(existing: U256, sqrt_price: U256, tick: i32) -> U256 { - // Mask to preserve bits 184-255 (observation fields) - let lower_mask: U256 = (U256::from(1u64) << 184) - U256::from(1u64); - let upper_preserved = existing & !lower_mask; - // Encode tick as 24-bit unsigned (two's complement for negative values) - let tick_bits = (tick as u32) & 0xFFFFFF; - upper_preserved | sqrt_price | (U256::from(tick_bits) << 160) -} - -/// Decode a V2 packed reserves slot (slot 8) into reserve0 and reserve1. -/// -/// V2 slot 8 packing: `reserve0(uint112) | reserve1(uint112) << 112 | blockTimestampLast(uint32) << 224` -pub fn decode_v2_reserves_raw(raw: U256) -> (u128, u128) { - // reserve0 is the lower 112 bits, reserve1 is bits 112..223 - let limbs = raw.as_limbs(); // [bits 0-63, 64-127, 128-191, 192-255] - let reserve0: u128 = (limbs[0] as u128 | ((limbs[1] as u128) << 64)) & ((1u128 << 112) - 1); - let shifted: U256 = raw >> 112; - let shifted_limbs = shifted.as_limbs(); - let reserve1: u128 = - (shifted_limbs[0] as u128 | ((shifted_limbs[1] as u128) << 64)) & ((1u128 << 112) - 1); - (reserve0, reserve1) -} - -/// Decode a V3 slot0 packed value into sqrtPriceX96 and tick. -/// -/// V3 slot 0 packing: `sqrtPriceX96(uint160) | tick(int24) << 160 | ...` -pub fn decode_v3_slot0_raw(raw: U256) -> (U256, i32) { - let mask_160: U256 = (U256::from(1u64) << 160) - U256::from(1u64); - let sqrt_price = raw & mask_160; - // tick is int24 at bits 160..183 - let shifted: U256 = raw >> 160; - let tick_raw = shifted.as_limbs()[0] as u32 & 0xFFFFFF; - // Sign-extend from 24-bit - let tick = if tick_raw & 0x800000 != 0 { - (tick_raw | 0xFF000000) as i32 - } else { - tick_raw as i32 - }; - (sqrt_price, tick) -} - -/// Decode raw tick info storage slots into an `Info` struct. -/// -/// slot0_val: liquidityGross(u128, lower 128 bits) | liquidityNet(i128, upper 128 bits) -/// slot3_val: initialized flag at bit 248 -pub fn decode_v3_tick_info_raw(slot0_val: U256, slot3_val: U256) -> Info { - let limbs = slot0_val.as_limbs(); - let liquidity_gross: u128 = limbs[0] as u128 | ((limbs[1] as u128) << 64); - let liquidity_net: i128 = (limbs[2] as u128 | ((limbs[3] as u128) << 64)) as i128; - let initialized = (slot3_val >> 248) & U256::from(1u64) != U256::ZERO; - Info { - liquidity_gross, - liquidity_net, - initialized, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_v2_reserves_round_trip() { - let r0: u128 = 1_000_000_000_000; // 1 trillion - let r1: u128 = 500_000_000; - let packed = encode_v2_reserves_raw(r0, r1); - let (decoded_r0, decoded_r1) = decode_v2_reserves_raw(packed); - assert_eq!(decoded_r0, r0); - assert_eq!(decoded_r1, r1); - } - - #[test] - fn test_v2_reserves_max_values() { - // uint112 max = 2^112 - 1 - let max_112: u128 = (1u128 << 112) - 1; - let packed = encode_v2_reserves_raw(max_112, max_112); - let (decoded_r0, decoded_r1) = decode_v2_reserves_raw(packed); - assert_eq!(decoded_r0, max_112); - assert_eq!(decoded_r1, max_112); - } - - #[test] - fn test_v3_slot0_round_trip_positive_tick() { - // Simulate a real slot0 value with observation fields set - let observation_fields = U256::from(0xABCDu64) << 184; // some data in upper bits - let original_price = U256::from(79228162514264337593543950336u128); // ~1.0 price - let _original_tick: i32 = 100; - let original_slot0 = observation_fields | original_price | (U256::from(100u32) << 160); - - let new_price = U256::from(79228162514264337593543950000u128); - let new_tick: i32 = 200; - let patched = encode_v3_slot0_patch(original_slot0, new_price, new_tick); - - let (decoded_price, decoded_tick) = decode_v3_slot0_raw(patched); - assert_eq!(decoded_price, new_price); - assert_eq!(decoded_tick, new_tick); - - // Verify observation fields preserved - let lower_mask: U256 = (U256::from(1u64) << 184) - U256::from(1u64); - assert_eq!(patched & !lower_mask, observation_fields); - } - - #[test] - fn test_v3_slot0_round_trip_negative_tick() { - let observation_fields = U256::from(0x1234u64) << 184; - let original_slot0 = observation_fields; - - let price = U256::from(12345678u64); - let tick: i32 = -100; - let patched = encode_v3_slot0_patch(original_slot0, price, tick); - - let (decoded_price, decoded_tick) = decode_v3_slot0_raw(patched); - assert_eq!(decoded_price, price); - assert_eq!(decoded_tick, tick); - } - - #[test] - fn test_v3_slot0_round_trip_extreme_negative_tick() { - let observation_fields = U256::ZERO; - let price = U256::from(4295128739u64); // MIN_SQRT_RATIO - let tick: i32 = -887272; // MIN_TICK - - let patched = encode_v3_slot0_patch(observation_fields, price, tick); - let (decoded_price, decoded_tick) = decode_v3_slot0_raw(patched); - assert_eq!(decoded_price, price); - assert_eq!(decoded_tick, tick); - } -} diff --git a/src/cache_sync/freshness.rs b/src/cache_sync/freshness.rs deleted file mode 100644 index f5a5357..0000000 --- a/src/cache_sync/freshness.rs +++ /dev/null @@ -1,366 +0,0 @@ -use super::*; - -/// Result of a pool freshness check. -#[derive(Debug, Clone)] -pub struct PoolFreshnessResult { - pub pool_address: Address, - pub pool_type: &'static str, - pub is_fresh: bool, - /// Maximum observed drift across all checked values, in basis points. - pub max_drift_bps: f64, - pub drift_description: Option, - /// Fresh V2 reserves when stale (reserve_0, reserve_1). Enables in-place resync - /// without redundant RPC calls since the freshness check already fetched these. - pub fresh_v2_reserves: Option<(u128, u128)>, - /// Fresh V3 state when stale (sqrt_price, tick, liquidity). Enables in-place resync - /// without redundant RPC calls since the freshness check already fetched these. - pub fresh_v3_state: Option<(U256, i32, u128)>, -} - -/// Maximum reserve drift (bps) before a V2 pool is considered stale. -/// 50 bps = 0.5% reserve change. -const V2_RESERVE_DRIFT_TOLERANCE_BPS: f64 = 50.0; - -/// Check freshness of a UniswapV2 pool by comparing in-memory reserves with on-chain state. -/// -/// Returns a `PoolFreshnessResult` indicating whether the pool state is fresh. -/// A pool is considered stale if reserve drift exceeds the configured threshold. -#[instrument(skip(cache), fields(pool = %pool.address))] -pub fn check_v2_freshness( - cache: &mut EvmCache, - pool: &UniswapV2Pool, -) -> Result { - // Purge all V2 pool storage to get fresh data from RPC. - // V2 pools have ~12 slots total, so full purge is nearly zero-cost. - cache.purge_pool_storage(pool.address); - - let fresh_reserves = call_view(cache, pool.address, IUniswapV2Pair::getReservesCall {})?; - - let fresh_r0 = fresh_reserves.reserve0.to::(); - let fresh_r1 = fresh_reserves.reserve1.to::(); - - let r0_matches = pool.reserve_0 == fresh_r0; - let r1_matches = pool.reserve_1 == fresh_r1; - - if r0_matches && r1_matches { - return Ok(PoolFreshnessResult { - pool_address: pool.address, - pool_type: "UniswapV2", - is_fresh: true, - max_drift_bps: 0.0, - drift_description: None, - fresh_v2_reserves: None, - fresh_v3_state: None, - }); - } - - // Calculate percentage drift - let r0_drift_bps = if pool.reserve_0 > 0 { - ((fresh_r0 as f64 - pool.reserve_0 as f64) / pool.reserve_0 as f64 * 10_000.0).abs() - } else { - 10_000.0 - }; - let r1_drift_bps = if pool.reserve_1 > 0 { - ((fresh_r1 as f64 - pool.reserve_1 as f64) / pool.reserve_1 as f64 * 10_000.0).abs() - } else { - 10_000.0 - }; - let max_drift_bps = r0_drift_bps.max(r1_drift_bps); - - if max_drift_bps <= V2_RESERVE_DRIFT_TOLERANCE_BPS { - debug!( - pool = %pool.address, - max_drift_bps = format!("{:.1}", max_drift_bps), - "V2 pool reserve drift within tolerance, treating as fresh" - ); - return Ok(PoolFreshnessResult { - pool_address: pool.address, - pool_type: "UniswapV2", - is_fresh: true, - max_drift_bps, - drift_description: None, - fresh_v2_reserves: None, - fresh_v3_state: None, - }); - } - - let r0_drift_pct = r0_drift_bps / 100.0; - let r1_drift_pct = r1_drift_bps / 100.0; - let description = format!( - "reserve0: {} -> {} ({:.2}% drift), reserve1: {} -> {} ({:.2}% drift)", - pool.reserve_0, fresh_r0, r0_drift_pct, pool.reserve_1, fresh_r1, r1_drift_pct - ); - - warn!( - pool = %pool.address, - cached_r0 = pool.reserve_0, - fresh_r0 = fresh_r0, - cached_r1 = pool.reserve_1, - fresh_r1 = fresh_r1, - max_drift_bps = format!("{:.1}", max_drift_bps), - "V2 pool reserves have drifted beyond tolerance" - ); - - Ok(PoolFreshnessResult { - pool_address: pool.address, - pool_type: "UniswapV2", - is_fresh: false, - max_drift_bps, - drift_description: Some(description), - fresh_v2_reserves: Some((fresh_r0, fresh_r1)), - fresh_v3_state: None, - }) -} - -/// Maximum absolute tick drift before a V3 pool is considered stale. -/// Each tick ≈ 1 bps (0.01%) price change. 10 ticks ≈ 0.1% price movement. -const V3_TICK_DRIFT_TOLERANCE: i32 = 10; - -/// Maximum liquidity drift (bps) before a V3 pool is considered stale. -/// 50 bps = 0.5% liquidity change — minimal impact on swap output. -const V3_LIQUIDITY_DRIFT_TOLERANCE_BPS: f64 = 50.0; - -/// Check freshness of a UniswapV3 pool by comparing in-memory state with on-chain slot0 -/// and liquidity, using drift tolerances rather than exact matching. -/// -/// Returns a `PoolFreshnessResult` indicating whether the pool state is fresh. -/// A pool is considered stale if tick or liquidity drift exceeds configured thresholds. -#[instrument(skip(cache), fields(pool = %pool.address))] -pub fn check_v3_freshness( - cache: &mut EvmCache, - pool: &UniswapV3Pool, -) -> Result { - // Selectively purge only slot0 and liquidity to get fresh data from RPC. - cache.purge_pool_slots(pool.address, &[V3_SLOT0_SLOT, V3_LIQUIDITY_SLOT]); - - let fresh_slot0 = call_view(cache, pool.address, IUniswapV3Pool::slot0Call {})?; - let fresh_liquidity = call_view(cache, pool.address, IUniswapV3Pool::liquidityCall {})?; - - let fresh_tick = fresh_slot0.tick.as_i32(); - - // Compute actual drift metrics - let tick_drift_abs = (fresh_tick - pool.tick).unsigned_abs() as i32; - let tick_drift_bps = tick_drift_abs as f64; // 1 tick ≈ 1 bps - - let liquidity_drift_bps = if pool.liquidity > 0 { - ((fresh_liquidity as f64 - pool.liquidity as f64) / pool.liquidity as f64 * 10_000.0).abs() - } else if fresh_liquidity > 0 { - 10_000.0 - } else { - 0.0 - }; - - let max_drift_bps = tick_drift_bps.max(liquidity_drift_bps); - - let within_tolerance = tick_drift_abs <= V3_TICK_DRIFT_TOLERANCE - && liquidity_drift_bps <= V3_LIQUIDITY_DRIFT_TOLERANCE_BPS; - - if !within_tolerance { - let description = format!( - "tick: {} -> {} (drift: {} ticks, {:.1} bps), liquidity: {} -> {} (drift: {:.1} bps)", - pool.tick, - fresh_tick, - tick_drift_abs, - tick_drift_bps, - pool.liquidity, - fresh_liquidity, - liquidity_drift_bps - ); - - warn!( - pool = %pool.address, - tick_drift = tick_drift_abs, - liquidity_drift_bps = format!("{:.1}", liquidity_drift_bps), - "V3 pool state has drifted beyond tolerance" - ); - - return Ok(PoolFreshnessResult { - pool_address: pool.address, - pool_type: "UniswapV3", - is_fresh: false, - max_drift_bps, - drift_description: Some(description), - fresh_v2_reserves: None, - fresh_v3_state: Some((fresh_slot0.sqrtPriceX96.to(), fresh_tick, fresh_liquidity)), - }); - } - - // Within tolerance — log minor drift at debug level - if tick_drift_abs > 0 || pool.liquidity != fresh_liquidity { - debug!( - pool = %pool.address, - tick_drift = tick_drift_abs, - liquidity_drift_bps = format!("{:.1}", liquidity_drift_bps), - "V3 pool drift within tolerance, treating as fresh" - ); - } - - Ok(PoolFreshnessResult { - pool_address: pool.address, - pool_type: "UniswapV3", - is_fresh: true, - max_drift_bps, - drift_description: None, - fresh_v2_reserves: None, - fresh_v3_state: None, - }) -} - -/// Check freshness of a Balancer pool by comparing the last known on-chain -/// `lastChangeBlock` with the current on-chain value, and measuring balance -/// drift in basis points. -/// -/// Drift below `BALANCER_FRESHNESS_TOLERANCE_BPS` is treated as fresh (absorbs -/// f64 round-trip noise and negligible on-chain dust). Drift above the threshold -/// is flagged as stale. -/// -/// **Important**: The caller should purge the vault's storage once before calling -/// this in a batch (the vault is shared across Balancer pools). -#[instrument(skip(cache), fields(pool_id = %pool.pool_id))] -pub fn check_balancer_freshness( - cache: &mut EvmCache, - pool: &BalancerPool, -) -> Result { - let pool_address = BalancerPool::address(pool.pool_id); - - // Fetch fresh pool tokens data from vault (vault storage should already be purged by caller) - let tokens_result = call_view( - cache, - pool.vault, - IBalancerVault::getPoolTokensCall { - poolId: pool.pool_id, - }, - )?; - - // Compare fresh balances against the cached immutable snapshot's lastChangeBlock - // and calculate drift against current on-chain balances. - // We use the on-chain U256 balances directly rather than round-tripping through - // f64 (which introduces precision noise that would cause false positives). - let cached_metadata = cache.immutable_cache().get_balancer_pool(pool.pool_id); - - // Build a map of fresh balances by token for drift calculation - let fresh_balance_map: HashMap = tokens_result - .tokens - .iter() - .zip(tokens_result.balances.iter()) - .map(|(&token, &balance)| (token, balance)) - .collect(); - - // Get the cached on-chain balances from immutable cache (these are the U256 values - // we last saw from getPoolTokens, NOT the f64-roundtripped values from the pool). - // If we have cached metadata with a lastChangeBlock, compare blocks first. - let mut max_drift_bps = 0.0f64; - let mut drift_details = Vec::new(); - - if let Some(ref metadata) = cached_metadata { - // Compare each token's balance using the fresh on-chain values - for token in metadata.tokens.iter() { - let fresh_bal = fresh_balance_map.get(token).copied().unwrap_or(U256::ZERO); - // We need the cached on-chain balance -- reconstruct it from the pool's last refresh. - // Since we don't store raw U256 balances separately, use the f64-roundtripped value - // but apply the tolerance threshold to absorb precision noise. - let cached_balances = pool.balances_u256(); - let cached_bal = cached_balances - .iter() - .find(|(t, _)| t == token) - .map(|(_, b)| *b) - .unwrap_or(U256::ZERO); - - if cached_bal != fresh_bal { - let cached_f: f64 = cached_bal.try_into().unwrap_or(u128::MAX) as f64; - let fresh_f: f64 = fresh_bal.try_into().unwrap_or(u128::MAX) as f64; - let drift_bps = if cached_f > 0.0 { - ((fresh_f - cached_f) / cached_f * 10_000.0).abs() - } else if fresh_f > 0.0 { - 10_000.0 - } else { - 0.0 - }; - max_drift_bps = max_drift_bps.max(drift_bps); - drift_details.push(format!( - "{}: {} -> {} ({:.2} bps)", - token, cached_bal, fresh_bal, drift_bps - )); - } - } - } - - // Apply tolerance: drifts below threshold are considered fresh - let is_fresh = max_drift_bps < BALANCER_FRESHNESS_TOLERANCE_BPS; - - if is_fresh { - if !drift_details.is_empty() { - debug!( - pool = %pool_address, - max_drift_bps = format!("{:.2}", max_drift_bps), - tolerance_bps = BALANCER_FRESHNESS_TOLERANCE_BPS, - "Balancer pool drift within tolerance, treating as fresh" - ); - } - Ok(PoolFreshnessResult { - pool_address, - pool_type: "Balancer", - is_fresh: true, - max_drift_bps, - drift_description: None, - fresh_v2_reserves: None, - fresh_v3_state: None, - }) - } else { - let description = drift_details.join(", "); - warn!( - pool = %pool_address, - max_drift_bps = format!("{:.2}", max_drift_bps), - tolerance_bps = BALANCER_FRESHNESS_TOLERANCE_BPS, - "Balancer pool balances have drifted beyond tolerance" - ); - - Ok(PoolFreshnessResult { - pool_address, - pool_type: "Balancer", - is_fresh: false, - max_drift_bps, - drift_description: Some(description), - fresh_v2_reserves: None, - fresh_v3_state: None, - }) - } -} - -/// Check freshness of multiple pools used in an execution plan. -/// -/// This function should be called immediately before submitting a transaction -/// to catch any state drift that occurred during the search cycle. -/// -/// Returns a vector of freshness results for all checked pools. -/// If any pool is stale, the caller should consider re-running the search. -#[instrument(skip(cache, v2_pools, v3_pools))] -pub fn check_pools_freshness( - cache: &mut EvmCache, - v2_pools: &[&UniswapV2Pool], - v3_pools: &[&UniswapV3Pool], -) -> Result> { - let mut results = Vec::with_capacity(v2_pools.len() + v3_pools.len()); - - for pool in v2_pools { - results.push(check_v2_freshness(cache, pool)?); - } - - for pool in v3_pools { - results.push(check_v3_freshness(cache, pool)?); - } - - // Log summary - let stale_count = results.iter().filter(|r| !r.is_fresh).count(); - if stale_count > 0 { - warn!( - total_pools = results.len(), - stale_pools = stale_count, - "Some pools have stale state - consider re-running search" - ); - } else { - debug!(total_pools = results.len(), "All pools have fresh state"); - } - - Ok(results) -} diff --git a/src/cache_sync/mod.rs b/src/cache_sync/mod.rs deleted file mode 100644 index ee9a250..0000000 --- a/src/cache_sync/mod.rs +++ /dev/null @@ -1,1556 +0,0 @@ -//! Forked-state pool initialization and incremental synchronization. -//! -//! Each submodule loads and refreshes one AMM family from an [`evm_fork_cache::cache::EvmCache`]: -//! Uniswap V2 reserves, Uniswap/Pancake V3 slot0/liquidity/ticks, Balancer V2 -//! and V3 balances, Curve reserves and parameters, Solidly V2 reserves, and -//! Slipstream concentrated-liquidity state. It also exposes the adaptive -//! bitmap-scan tuning, freshness checks, and the prefetch helpers used to warm -//! V3 tick storage in parallel before simulation. -//! -//! Most callers do not use these functions directly; they drive them through -//! the higher-level loaders in [`crate::configured_amms`]. - -mod adaptive_prefetch; -mod balancer_sync; -mod balancer_v3_sync; -pub(crate) mod compat; -mod curve_sync; -mod decode; -mod freshness; -mod slipstream_sync; -mod solidly_v2_sync; -mod v2_sync; -mod v3_bitmap; -mod v3_sync; - -pub use balancer_sync::{init_balancer_from_cache, refresh_balancer_pool}; -pub use balancer_v3_sync::{init_balancer_v3_from_cache, refresh_balancer_v3_pool}; -pub use curve_sync::{init_curve_from_cache, refresh_curve_reserves}; -pub use decode::{ - decode_v2_reserves_raw, decode_v3_slot0_raw, decode_v3_tick_info_raw, encode_v2_reserves_raw, - encode_v3_slot0_patch, -}; -pub use freshness::{ - PoolFreshnessResult, check_balancer_freshness, check_pools_freshness, check_v2_freshness, - check_v3_freshness, -}; -pub use slipstream_sync::init_slipstream_from_cache; -pub use solidly_v2_sync::{init_solidly_v2_from_cache, refresh_solidly_v2_reserves}; -pub use v2_sync::{init_uniswap_v2_from_cache, refresh_uniswap_v2_reserves}; -pub(crate) use v3_bitmap::compute_adaptive_scan_params; -pub use v3_bitmap::{V3Flavor, build_v3_factory_map, needs_tick_resync}; -pub use v3_sync::{ - V3BitmapPrefetchTarget, V3InitPhase1Result, V3PrefetchStats, extend_v3_tick_region, - incremental_sync_v3_ticks, init_pancakeswap_v3_from_cache, init_uniswap_v3_from_cache, - init_v3_phase1, inject_v3_tick_data, prefetch_v3_bitmap_slots, - prefetch_v3_incremental_resync_slots, prefetch_v3_tick_info_slots, - refresh_pancakeswap_v3_state, refresh_uniswap_v3_slot0, refresh_uniswap_v3_state, - save_v3_tick_snapshot, sync_uniswap_v3_ticks, sync_uniswap_v3_ticks_full, targeted_tick_resync, - targeted_tick_resync_with_injected_slot0, -}; - -use std::collections::{HashMap, HashSet}; -use std::sync::{Arc, RwLock}; -use std::time::{Instant, SystemTime, UNIX_EPOCH}; - -use alloy_primitives::{Address, B256, U256}; -use alloy_sol_types::{SolCall, sol}; -use amms::amms::{ - Token, - uniswap_v2::UniswapV2Pool, - uniswap_v3::{Info, UniswapV3Pool}, -}; -use anyhow::{Result, anyhow}; -use foundry_fork_db::backend::BlockingMode; -use futures::future::join_all; -use revm::database_interface::DatabaseRef; -use tracing::{debug, info, instrument, warn}; - -use self::compat::{ - BalancerPoolMetadata, EvmCacheProtocolExt, ImmutableDataCacheProtocolExt, V2PoolMetadata, - V3PoolMetadata, V3PoolTickSnapshot, -}; -use crate::adapters::storage::{ - PANCAKE_V3_LIQUIDITY_SLOT, PANCAKE_V3_TICK_BITMAP_BASE_SLOT, PANCAKE_V3_TICKS_BASE_SLOT, - SLIPSTREAM_LIQUIDITY_SLOT, SLIPSTREAM_SLOT0_SLOT, SLIPSTREAM_TICK_BITMAP_BASE_SLOT, - SLIPSTREAM_TICKS_BASE_SLOT, V2_RESERVES_SLOT, V3_LIQUIDITY_SLOT, V3_SLOT0_SLOT, - V3_TICK_BITMAP_BASE_SLOT, V3_TICKS_BASE_SLOT, v3_tick_bitmap_storage_key, - v3_tick_bitmap_storage_key_with_base, v3_tick_info_storage_keys, - v3_tick_info_storage_keys_with_base, -}; -use crate::amm_wrapper::LocalAMM; -use crate::balancer_math::WeightedPool; -use crate::balancer_pool::BalancerPool; -use crate::data::PoolParams; -use crate::progress::{finish_with_message, progress_bar}; -use crate::tuning::{SyncSpeedMode, sync_speed_mode}; -use evm_fork_cache::cache::{EvmCache, SlotObservationTracker}; -use evm_fork_cache::freshness::FreshnessParams; - -/// Shared AMM reference type used by synchronization entry points. -pub type AMMRef = Arc>; - -pub(crate) const MIN_TICK: i32 = -887_272; -pub(crate) const MAX_TICK: i32 = 887_272; - -/// Maximum tick drift allowed before invalidating the cache. -/// If the tick has moved more than this many ticks since the snapshot was taken, -/// the cache is considered potentially stale and should be re-verified. -/// 256 ticks = 1 word, so this allows movement within ~4 words before triggering re-validation. -pub(crate) const MAX_TICK_DRIFT_FOR_CACHE: i32 = 1024; - -fn current_observation_time() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_secs()) - .unwrap_or_default() -} - -/// Maximum number of words to scan in each direction from the current tick. -/// This is a safety limit to prevent runaway scans. With tick_spacing=1, -/// 500 words covers ~128,000 ticks in each direction. -/// Scaled by speed mode to control RPC consumption. -pub(crate) fn max_scan_radius() -> i32 { - match sync_speed_mode() { - SyncSpeedMode::Fast => 500, - SyncSpeedMode::Normal => 350, - SyncSpeedMode::Slow => 200, - SyncSpeedMode::XSlow => 50, - } -} - -/// Maximum concurrent storage slot prefetch operations. -/// Scaled by speed mode to control RPC provider load. -pub(crate) fn max_concurrent_storage_prefetch() -> usize { - match sync_speed_mode() { - SyncSpeedMode::Fast => 30, - SyncSpeedMode::Normal => 20, - SyncSpeedMode::Slow => 12, - SyncSpeedMode::XSlow => 4, - } -} - -/// Delay between prefetch chunks to avoid RPC rate limiting. -/// Scaled by speed mode to give providers more breathing room. -pub(crate) fn prefetch_inter_chunk_delay() -> std::time::Duration { - match sync_speed_mode() { - SyncSpeedMode::Fast => std::time::Duration::from_millis(15), - SyncSpeedMode::Normal => std::time::Duration::from_millis(30), - SyncSpeedMode::Slow => std::time::Duration::from_millis(50), - SyncSpeedMode::XSlow => std::time::Duration::from_millis(200), - } -} - -/// Number of bitmap words around the current tick to refresh every cycle. -/// -/// This is the per-cycle hot zone — smaller than the incremental resync hot zone -/// since it runs every cycle. Scaled by speed mode. -pub(crate) fn cycle_hot_zone_radius() -> i32 { - match sync_speed_mode() { - SyncSpeedMode::Fast => 4, - SyncSpeedMode::Normal => 3, - SyncSpeedMode::Slow => 2, - SyncSpeedMode::XSlow => 1, - } -} - -/// Number of bitmap words around the current tick that are always re-fetched -/// during incremental resync, regardless of whether the bitmap changed. -/// -/// This catches liquidity changes on already-initialized ticks (where the bitmap -/// bit stays 1 but liquidityGross/Net changed). Scaled by speed mode. -pub(crate) fn incremental_hot_zone_radius() -> i32 { - match sync_speed_mode() { - SyncSpeedMode::Fast => 4, - SyncSpeedMode::Normal => 3, - SyncSpeedMode::Slow => 2, - SyncSpeedMode::XSlow => 1, - } -} - -/// Maximum drift (in basis points) before a Balancer pool is considered stale. -/// -/// Balancer balances are stored internally as f64 and round-tripped through -/// `f64_to_u256_lossy`, which introduces precision noise of ~1e-15 relative error. -/// A threshold of 10 bps (0.1%) safely absorbs this noise while still catching -/// meaningful balance changes that could affect trade profitability. -pub(crate) const BALANCER_FRESHNESS_TOLERANCE_BPS: f64 = 10.0; - -sol!( - #[sol(rpc)] - contract IBalancerPool { - function getNormalizedWeights() external view returns (uint256[] memory); - function getSwapFeePercentage() external view returns (uint256); - } -); - -sol!( - #[sol(rpc)] - contract IBalancerVault { - function getPoolTokens(bytes32 poolId) - external - view - returns (address[] memory tokens, uint256[] memory balances, uint256 lastChangeBlock); - } -); - -sol!( - #[sol(rpc)] - contract IUniswapV3Pool { - function token0() external view returns (address); - function token1() external view returns (address); - function fee() external view returns (uint24); - function tickSpacing() external view returns (int24); - function slot0() - external - view - returns ( - uint160 sqrtPriceX96, - int24 tick, - uint16 observationIndex, - uint16 observationCardinality, - uint16 observationCardinalityNext, - uint8 feeProtocol, - bool unlocked - ); - function liquidity() external view returns (uint128); - function tickBitmap(int16 wordPosition) external view returns (uint256); - function ticks(int24 tick) - external - view - returns ( - uint128 liquidityGross, - int128 liquidityNet, - uint256 feeGrowthOutside0X128, - uint256 feeGrowthOutside1X128, - int56 tickCumulativeOutside, - uint160 secondsPerLiquidityOutsideX128, - uint32 secondsOutside, - bool initialized - ); - } -); - -sol!( - #[sol(rpc)] - contract IUniswapV2Pair { - function token0() external view returns (address); - function token1() external view returns (address); - function getReserves() external view returns (uint112 reserve0, uint112 reserve1, uint32 blockTimestampLast); - } -); - -pub(crate) fn call_view( - cache: &mut EvmCache, - to: Address, - call: C, -) -> Result { - let result = cache.call_raw(Address::ZERO, to, call.abi_encode().into(), false)?; - match result { - revm::context::result::ExecutionResult::Success { output, .. } => { - let out = output.into_data(); - let decoded = C::abi_decode_returns(&out) - .map_err(|e| anyhow!("Failed to decode view call: {:?}", e))?; - Ok(decoded) - } - other => Err(anyhow!("Call failed: {:?}", other)), - } -} - -/// Prefetch all accounts needed for a list of AMM addresses in parallel. -/// -/// This is a convenience function that prefetches accounts without knowing -/// the AMM type. It's useful for generic address lists. -#[instrument(skip(cache, addresses), fields(address_count = addresses.len()))] -pub async fn prefetch_accounts(cache: &mut EvmCache, addresses: &[Address]) -> Result<()> { - prefetch_accounts_parallel(cache, addresses, "account").await -} - -/// Prefetch account data for multiple UniswapV2 pools in parallel. -/// -/// This fetches the pool contracts so that subsequent `init_uniswap_v2_from_cache` -/// calls can hit the cache instead of making RPC calls. -#[instrument(skip(cache, addresses), fields(pool_count = addresses.len()))] -pub async fn prefetch_v2_pool_accounts(cache: &mut EvmCache, addresses: &[Address]) -> Result<()> { - prefetch_accounts_parallel(cache, addresses, "V2 pool").await -} - -/// Prefetch account data for multiple UniswapV3 pools in parallel. -/// -/// This fetches the pool contracts so that subsequent `init_uniswap_v3_from_cache` -/// calls can hit the cache instead of making RPC calls. -#[instrument(skip(cache, addresses), fields(pool_count = addresses.len()))] -pub async fn prefetch_v3_pool_accounts(cache: &mut EvmCache, addresses: &[Address]) -> Result<()> { - prefetch_accounts_parallel(cache, addresses, "V3 pool").await -} - -/// Prefetch account data for multiple Balancer pools in parallel. -/// -/// This fetches both the pool contract and vault contract for each pool. -#[instrument(skip(cache, pools), fields(pool_count = pools.len()))] -pub async fn prefetch_balancer_pool_accounts( - cache: &mut EvmCache, - pools: &[(B256, Address)], // (pool_id, vault_address) -) -> Result<()> { - if pools.is_empty() { - return Ok(()); - } - - // Collect all unique addresses to prefetch using HashSet for O(1) dedup - let mut seen: HashSet
= HashSet::with_capacity(pools.len() * 2); - let mut addresses: Vec
= Vec::with_capacity(pools.len() * 2); - for (pool_id, vault) in pools { - let pool_addr = BalancerPool::address(*pool_id); - if seen.insert(pool_addr) { - addresses.push(pool_addr); - } - if seen.insert(*vault) { - addresses.push(*vault); - } - } - - prefetch_accounts_parallel(cache, &addresses, "Balancer pool").await -} - -/// Internal parallel prefetch implementation. -/// -/// Uses the SharedBackend's ability to be cloned and used from multiple tasks. -/// The backend uses channels internally, so cloning is cheap and all requests -/// go through the same background handler. -async fn prefetch_accounts_parallel( - cache: &mut EvmCache, - addresses: &[Address], - label: &str, -) -> Result<()> { - if addresses.is_empty() { - return Ok(()); - } - - // Filter out addresses already in cache - let addresses_to_fetch: Vec
= addresses - .iter() - .filter(|addr| !cache.db_mut().cache.accounts.contains_key(*addr)) - .copied() - .collect(); - - if addresses_to_fetch.is_empty() { - debug!( - total = addresses.len(), - "all {} accounts already cached", label - ); - return Ok(()); - } - - // Use BlockingMode::Block so basic_ref calls can run on the blocking thread pool - // without calling block_in_place (which would starve tokio worker threads). - let backend = cache - .unchecked_backend() - .with_blocking_mode(BlockingMode::Block); - - let result = adaptive_prefetch::run_adaptive_prefetch( - &addresses_to_fetch, - adaptive_prefetch::AdaptivePrefetchConfig::throttle_aware(), - &format!("Fetching {}", label), - |addr, idx| { - let backend = backend.clone(); - let addr = *addr; - tokio::task::spawn_blocking(move || { - backend.basic_ref(addr).map(|_| ()).map_err(|_| idx) - }) - }, - ) - .await; - - debug!( - success_count = result.success_count, - error_count = result.error_count, - retry_rounds = result.retry_rounds, - total = addresses.len(), - cached = addresses.len() - addresses_to_fetch.len(), - "prefetched {} accounts in parallel", - label - ); - - Ok(()) -} - -// ============================================================================ -// Parallel Pool State Refresh -// ============================================================================ -// -// Parallel alternative to a sequential per-pool refresh. Uses cloned -// SharedBackend instances for parallel raw storage reads, keeping serial -// phases only for fast in-memory operations (purging, decoding, pool updates). - -/// Work item for a V2 pool during parallel refresh. -struct V2PoolWork { - address: Address, - pool: UniswapV2Pool, -} - -/// Work item for a V3 pool during parallel refresh. -struct V3PoolWork { - address: Address, - pool: UniswapV3Pool, - hot_zone_words: Vec, - flavor: V3Flavor, -} - -/// Work item for a Balancer pool during parallel refresh. -struct BalancerPoolWork { - address: Address, - pool: BalancerPool, -} - -/// Work item for a Balancer V3 pool during sequential refresh. -struct BalancerV3PoolWork { - address: Address, - pool: crate::balancer_v3_pool::BalancerV3Pool, -} - -/// Work item for a Curve pool during sequential refresh. -struct CurvePoolWork { - address: Address, - pool: crate::curve_pool::CurvePool, -} - -/// Refresh all pool state (V2 reserves + V3 slot0/ticks) using parallel raw storage reads. -/// -/// This is functionally equivalent to refreshing each pool sequentially, but -/// fetches storage slots in parallel via cloned `SharedBackend` instances, which -/// dramatically reduces wall-clock time for large pool sets. -#[allow(clippy::needless_late_init)] -pub async fn sync_all_pool_state_parallel( - cache: &mut EvmCache, - amms: &mut HashMap, -) -> Result<()> { - let fn_start = Instant::now(); - let phase_classify_ms: u64; - let phase_purge_ms: u64; - let phase_fetch_ms: u64; - let phase_decode_ms: u64; - let phase_bitmap_ms: u64; - let phase_tick_ms: u64; - let phase_writeback_ms: u64; - - // -- Phase 0: Classify and extract pool data -- - let mut v2_work: Vec = Vec::new(); - let mut v3_work: Vec = Vec::new(); - let mut balancer_work: Vec = Vec::new(); - let mut balancer_v3_work: Vec = Vec::new(); - let mut curve_work: Vec = Vec::new(); - - for (addr, amm_ref) in amms.iter() { - let guard = match amm_ref.read() { - Ok(g) => g, - Err(_) => continue, - }; - match &*guard { - LocalAMM::UniswapV2(p) => { - v2_work.push(V2PoolWork { - address: *addr, - pool: p.clone(), - }); - } - LocalAMM::UniswapV3(p) | LocalAMM::PancakeSwapV3(p) => { - let flavor = if matches!(&*guard, LocalAMM::PancakeSwapV3(_)) { - V3Flavor::PancakeSwapV3 - } else { - V3Flavor::UniswapV3 - }; - if p.tick_spacing == 0 || p.tick_bitmap.is_empty() { - v3_work.push(V3PoolWork { - address: *addr, - pool: p.clone(), - hot_zone_words: Vec::new(), - flavor, - }); - } else { - let current_word = v3_bitmap::tick_to_word(p.tick, p.tick_spacing); - let hot_zone_words: Vec = ((-cycle_hot_zone_radius()) - ..=cycle_hot_zone_radius()) - .map(|offset| (current_word + offset) as i16) - .collect(); - v3_work.push(V3PoolWork { - address: *addr, - pool: p.clone(), - hot_zone_words, - flavor, - }); - } - } - LocalAMM::SolidlyV2(_) => { - // SolidlyV2 refreshed via getReserves() call after parallel phase - } - LocalAMM::Slipstream(p) => { - let v3 = p.as_v3_pool(); - let flavor = V3Flavor::Slipstream; - if v3.tick_spacing == 0 || v3.tick_bitmap.is_empty() { - v3_work.push(V3PoolWork { - address: *addr, - pool: v3, - hot_zone_words: Vec::new(), - flavor, - }); - } else { - let current_word = v3_bitmap::tick_to_word(v3.tick, v3.tick_spacing); - let hot_zone_words: Vec = ((-cycle_hot_zone_radius()) - ..=cycle_hot_zone_radius()) - .map(|offset| (current_word + offset) as i16) - .collect(); - v3_work.push(V3PoolWork { - address: *addr, - pool: v3, - hot_zone_words, - flavor, - }); - } - } - LocalAMM::Balancer(p) => { - balancer_work.push(BalancerPoolWork { - address: *addr, - pool: p.clone(), - }); - } - LocalAMM::BalancerV3(p) => { - balancer_v3_work.push(BalancerV3PoolWork { - address: *addr, - pool: p.clone(), - }); - } - LocalAMM::Curve(p) => { - curve_work.push(CurvePoolWork { - address: *addr, - pool: p.clone(), - }); - } - _ => {} // ERC4626, UniswapV4 stubs not refreshed per-cycle - } - } - - let total_pools = v2_work.len() - + v3_work.len() - + balancer_work.len() - + balancer_v3_work.len() - + curve_work.len(); - if total_pools == 0 { - return Ok(()); - } - - let pb = progress_bar(total_pools as u64, "Refreshing pool state (parallel)"); - phase_classify_ms = fn_start.elapsed().as_millis() as u64; - - // -- Phase 1: Batch purge mutable slots -- - let phase1_start = Instant::now(); - for w in &v2_work { - cache.purge_pool_storage(w.address); - } - for w in &v3_work { - // Refresh slot0/liquidity first and only fetch hot-zone bitmap words for - // pools whose active liquidity actually changed. - cache.purge_pool_slots(w.address, &[V3_SLOT0_SLOT, w.flavor.liquidity_slot()]); - } - // Purge Balancer vault storage (deduplicated by vault address — covers both V2 and V3 vaults) - { - let mut purged_vaults: HashSet
= HashSet::new(); - for w in &balancer_work { - if purged_vaults.insert(w.pool.vault) { - cache.purge_pool_storage(w.pool.vault); - } - } - for w in &balancer_v3_work { - if purged_vaults.insert(w.pool.vault) { - cache.purge_pool_storage(w.pool.vault); - } - } - } - - phase_purge_ms = phase1_start.elapsed().as_millis() as u64; - - // -- Phase 2: Parallel fetch via cloned backend -- - let phase2_start = Instant::now(); - let backend = cache.unchecked_backend().clone(); - - // Tag to identify what each fetched slot represents - #[derive(Clone, Copy)] - enum SlotTag { - V2Reserves(usize), // index into v2_work - V3Slot0(usize), // index into v3_work - V3Liquidity(usize), - } - - let mut fetch_requests: Vec<(Address, U256, SlotTag)> = Vec::new(); - - for (i, w) in v2_work.iter().enumerate() { - fetch_requests.push((w.address, V2_RESERVES_SLOT, SlotTag::V2Reserves(i))); - } - for (i, w) in v3_work.iter().enumerate() { - fetch_requests.push((w.address, V3_SLOT0_SLOT, SlotTag::V3Slot0(i))); - fetch_requests.push(( - w.address, - w.flavor.liquidity_slot(), - SlotTag::V3Liquidity(i), - )); - } - - // Fetch all slots in parallel with adaptive rate limiting - let mut fetch_results: Vec> = vec![None; fetch_requests.len()]; - let mut fetch_errors: HashSet = HashSet::new(); // pool work indices with errors - - let mut adaptive = adaptive_prefetch::AdaptiveState::new( - adaptive_prefetch::AdaptivePrefetchConfig::throttle_aware(), - ); - let mut offset = 0; - while offset < fetch_requests.len() { - let chunk_end = (offset + adaptive.current_chunk_size).min(fetch_requests.len()); - let chunk = &fetch_requests[offset..chunk_end]; - - let futures: Vec<_> = chunk - .iter() - .enumerate() - .map(|(local_idx, (addr, slot, _tag))| { - let backend = backend.clone(); - let addr = *addr; - let slot = *slot; - let global_idx = offset + local_idx; - async move { (global_idx, backend.storage_ref(addr, slot)) } - }) - .collect(); - - let results = join_all(futures).await; - let chunk_total = results.len(); - let mut chunk_failures = 0; - for (global_idx, result) in results { - match result { - Ok(val) => { - fetch_results[global_idx] = Some(val); - } - Err(e) => { - let (addr, _slot, tag) = &fetch_requests[global_idx]; - let pool_idx = match tag { - SlotTag::V2Reserves(i) => *i + v3_work.len(), // won't collide - SlotTag::V3Slot0(i) | SlotTag::V3Liquidity(i) => *i, - }; - warn!(%addr, error = ?e, "parallel fetch failed for pool slot"); - fetch_errors.insert(pool_idx); - chunk_failures += 1; - } - } - } - adaptive.adjust_after_chunk(chunk_total, chunk_failures); - tokio::time::sleep(adaptive.current_delay).await; - offset = chunk_end; - } - - phase_fetch_ms = phase2_start.elapsed().as_millis() as u64; - - // -- Phase 3: Decode and compare -- - let phase3_start = Instant::now(); - let mut v2_refreshed = 0u32; - let mut v3_refreshed = 0u32; - let mut full_resyncs = 0u32; - let mut v2_fallback_addrs: Vec
= Vec::new(); - let mut v3_fallback_addrs: Vec
= Vec::new(); - - // Decode V2 - for (req_idx, (_addr, _slot, tag)) in fetch_requests.iter().enumerate() { - if let SlotTag::V2Reserves(wi) = tag { - let w = &mut v2_work[*wi]; - if let Some(raw) = fetch_results[req_idx] { - let (r0, r1) = decode_v2_reserves_raw(raw); - if w.pool.reserve_0 != r0 || w.pool.reserve_1 != r1 { - debug!( - pool = %w.address, - old_r0 = w.pool.reserve_0, - new_r0 = r0, - old_r1 = w.pool.reserve_1, - new_r1 = r1, - "V2 reserves changed (parallel)" - ); - } - w.pool.reserve_0 = r0; - w.pool.reserve_1 = r1; - v2_refreshed += 1; - } else { - v2_fallback_addrs.push(w.address); - } - pb.inc(1); - } - } - - // Decode V3 slot0 + liquidity first. Hot-zone bitmap/tick refresh now only - // runs for pools whose active liquidity changed. - let mut v3_slot0_raw: Vec> = vec![None; v3_work.len()]; - let mut v3_liquidity_raw: Vec> = vec![None; v3_work.len()]; - - for (req_idx, (_addr, _slot, tag)) in fetch_requests.iter().enumerate() { - match tag { - SlotTag::V3Slot0(wi) => { - v3_slot0_raw[*wi] = fetch_results[req_idx]; - } - SlotTag::V3Liquidity(wi) => { - v3_liquidity_raw[*wi] = fetch_results[req_idx]; - } - _ => {} - } - } - - let mut v3_bitmap_refresh_requests: Vec<(Address, U256, usize, i16)> = Vec::new(); - let mut refreshed_words_per_pool: Vec> = vec![HashSet::new(); v3_work.len()]; - let mut ticks_to_fetch_per_pool: Vec> = vec![Vec::new(); v3_work.len()]; - let mut v3_liquidity_fast_paths = 0u32; - - for (wi, w) in v3_work.iter_mut().enumerate() { - // Check if this pool had any fetch errors - if fetch_errors.contains(&wi) { - v3_fallback_addrs.push(w.address); - pb.inc(1); - continue; - } - - // Decode slot0 - if let Some(raw) = v3_slot0_raw[wi] { - let (sqrt_price, tick) = decode_v3_slot0_raw(raw); - let old_tick = w.pool.tick; - w.pool.sqrt_price = sqrt_price; - w.pool.tick = tick; - - debug!( - pool = %w.address, - old_tick, - new_tick = tick, - "refreshed V3 slot0 (parallel)" - ); - } else { - v3_fallback_addrs.push(w.address); - pb.inc(1); - continue; - } - - // Decode liquidity - if let Some(raw) = v3_liquidity_raw[wi] { - let old_liquidity = w.pool.liquidity; - // liquidity is uint128, stored in lower 128 bits - let limbs = raw.as_limbs(); - let fresh_liquidity = limbs[0] as u128 | ((limbs[1] as u128) << 64); - w.pool.liquidity = fresh_liquidity; - - if w.hot_zone_words.is_empty() { - continue; - } - - if old_liquidity == fresh_liquidity { - v3_liquidity_fast_paths += 1; - continue; - } - - for &word in &w.hot_zone_words { - refreshed_words_per_pool[wi].insert(word); - let slot = w.flavor.tick_bitmap_key(word); - cache.purge_pool_slots(w.address, &[slot]); - v3_bitmap_refresh_requests.push((w.address, slot, wi, word)); - } - } else { - v3_fallback_addrs.push(w.address); - pb.inc(1); - continue; - } - } - - phase_decode_ms = phase3_start.elapsed().as_millis() as u64; - - // -- Phase 4: Parallel hot-zone bitmap fetch for liquidity-changed V3 pools -- - let phase4_start = Instant::now(); - let mut v3_bitmap_fresh: Vec> = vec![HashMap::new(); v3_work.len()]; - - if !v3_bitmap_refresh_requests.is_empty() { - let backend = cache.unchecked_backend().clone(); - let mut adaptive = adaptive_prefetch::AdaptiveState::new( - adaptive_prefetch::AdaptivePrefetchConfig::throttle_aware(), - ); - let mut offset = 0; - while offset < v3_bitmap_refresh_requests.len() { - let chunk_end = - (offset + adaptive.current_chunk_size).min(v3_bitmap_refresh_requests.len()); - let chunk = &v3_bitmap_refresh_requests[offset..chunk_end]; - - let futures: Vec<_> = chunk - .iter() - .enumerate() - .map(|(local_idx, (addr, slot, _wi, _word))| { - let backend = backend.clone(); - let addr = *addr; - let slot = *slot; - let global_idx = offset + local_idx; - async move { (global_idx, backend.storage_ref(addr, slot)) } - }) - .collect(); - - let results = join_all(futures).await; - let chunk_total = results.len(); - let mut chunk_failures = 0; - for (global_idx, result) in results { - match result { - Ok(val) => { - let (_addr, _slot, wi, word) = v3_bitmap_refresh_requests[global_idx]; - v3_bitmap_fresh[wi].insert(word, val); - } - Err(e) => { - let (addr, _slot, wi, _word) = v3_bitmap_refresh_requests[global_idx]; - warn!(%addr, error = ?e, "parallel fetch failed for V3 bitmap slot"); - fetch_errors.insert(wi); - chunk_failures += 1; - } - } - } - adaptive.adjust_after_chunk(chunk_total, chunk_failures); - tokio::time::sleep(adaptive.current_delay).await; - offset = chunk_end; - } - } - - for (wi, w) in v3_work.iter_mut().enumerate() { - if fetch_errors.contains(&wi) { - if !v3_fallback_addrs.contains(&w.address) { - v3_fallback_addrs.push(w.address); - } - continue; - } - - for &word in &refreshed_words_per_pool[wi] { - let fresh_bitmap = v3_bitmap_fresh[wi] - .get(&word) - .copied() - .unwrap_or(U256::ZERO); - let old_bitmap = w.pool.tick_bitmap.get(&word).copied().unwrap_or(U256::ZERO); - w.pool.tick_bitmap.insert(word, fresh_bitmap); - - if fresh_bitmap != U256::ZERO { - let word_ticks = v3_bitmap::extract_ticks_from_bitmap_word( - word as i32, - fresh_bitmap, - w.pool.tick_spacing, - ); - ticks_to_fetch_per_pool[wi].extend_from_slice(&word_ticks); - } - - if fresh_bitmap != old_bitmap { - debug!( - pool = %w.address, - word, - "V3 bitmap changed in refreshed hot zone" - ); - } - } - } - - phase_bitmap_ms = phase4_start.elapsed().as_millis() as u64; - - // -- Phase 5: Parallel tick info fetch -- - let phase5_start = Instant::now(); - // Purge tick info slots for ticks that need re-fetching - for (wi, ticks) in ticks_to_fetch_per_pool.iter().enumerate() { - if ticks.is_empty() { - continue; - } - let flavor = &v3_work[wi].flavor; - let slots_to_purge: Vec = ticks - .iter() - .flat_map(|&tick| { - let keys = flavor.tick_info_keys(tick); - [keys[0], keys[3]] // Only slots 0 and 3 - }) - .collect(); - cache.purge_pool_slots(v3_work[wi].address, &slots_to_purge); - } - - // Build flat list of tick info fetch requests - #[derive(Clone, Copy)] - enum TickSlotTag { - Slot0(usize, i32), // (v3_work index, tick) - Slot3(usize, i32), - } - - let mut tick_fetch_requests: Vec<(Address, U256, TickSlotTag)> = Vec::new(); - for (wi, ticks) in ticks_to_fetch_per_pool.iter().enumerate() { - let flavor = &v3_work[wi].flavor; - for &tick in ticks { - let keys = flavor.tick_info_keys(tick); - tick_fetch_requests.push((v3_work[wi].address, keys[0], TickSlotTag::Slot0(wi, tick))); - tick_fetch_requests.push((v3_work[wi].address, keys[3], TickSlotTag::Slot3(wi, tick))); - } - } - - // Fetch tick info in parallel - let mut tick_slot0_results: HashMap<(usize, i32), U256> = HashMap::new(); - let mut tick_slot3_results: HashMap<(usize, i32), U256> = HashMap::new(); - - if !tick_fetch_requests.is_empty() { - let backend = cache.unchecked_backend().clone(); - - let mut adaptive = adaptive_prefetch::AdaptiveState::new( - adaptive_prefetch::AdaptivePrefetchConfig::throttle_aware(), - ); - let mut offset = 0; - while offset < tick_fetch_requests.len() { - let chunk_end = (offset + adaptive.current_chunk_size).min(tick_fetch_requests.len()); - let chunk = &tick_fetch_requests[offset..chunk_end]; - - let futures: Vec<_> = chunk - .iter() - .enumerate() - .map(|(local_idx, (addr, slot, _tag))| { - let backend = backend.clone(); - let addr = *addr; - let slot = *slot; - let global_idx = offset + local_idx; - async move { (global_idx, backend.storage_ref(addr, slot)) } - }) - .collect(); - - let results = join_all(futures).await; - let chunk_total = results.len(); - let mut chunk_failures = 0; - for (global_idx, result) in results { - match result { - Ok(val) => { - let (_addr, _slot, tag) = &tick_fetch_requests[global_idx]; - match tag { - TickSlotTag::Slot0(wi, tick) => { - tick_slot0_results.insert((*wi, *tick), val); - } - TickSlotTag::Slot3(wi, tick) => { - tick_slot3_results.insert((*wi, *tick), val); - } - } - } - Err(_) => { - chunk_failures += 1; - } - } - } - adaptive.adjust_after_chunk(chunk_total, chunk_failures); - tokio::time::sleep(adaptive.current_delay).await; - offset = chunk_end; - } - } - - phase_tick_ms = phase5_start.elapsed().as_millis() as u64; - - // -- Phase 6: Update pools and handle edge cases -- - let phase6_start = Instant::now(); - for (wi, ticks) in ticks_to_fetch_per_pool.iter().enumerate() { - for &tick in ticks { - if let (Some(&s0), Some(&s3)) = ( - tick_slot0_results.get(&(wi, tick)), - tick_slot3_results.get(&(wi, tick)), - ) { - let info = decode_v3_tick_info_raw(s0, s3); - v3_work[wi].pool.ticks.insert(tick, info); - } - } - - if refreshed_words_per_pool[wi].is_empty() { - continue; - } - - let initialized_in_refreshed: HashSet = ticks.iter().copied().collect(); - let tick_spacing = v3_work[wi].pool.tick_spacing; - let refreshed_words = &refreshed_words_per_pool[wi]; - v3_work[wi].pool.ticks.retain(|&tick, _| { - let tick_word = v3_bitmap::tick_to_word(tick, tick_spacing) as i16; - !refreshed_words.contains(&tick_word) || initialized_in_refreshed.contains(&tick) - }); - } - - // Save snapshots and check for full resyncs - for w in v3_work.iter_mut() { - if v3_fallback_addrs.contains(&w.address) { - continue; - } - if !w.hot_zone_words.is_empty() { - save_v3_tick_snapshot(cache, &w.pool); - } - - // Check if tick moved to an unknown word - if w.pool.tick_bitmap.is_empty() { - // No tick data at all — need full scan (cold start edge case) - sync_uniswap_v3_ticks(cache, &mut w.pool, w.flavor)?; - full_resyncs += 1; - debug!( - pool = %w.address, - new_tick = w.pool.tick, - "performed full tick resync (no bitmap data)" - ); - } else if needs_tick_resync(&w.pool, w.pool.tick) { - // Tick moved to an unknown word — extend lazily instead of full rescan - extend_v3_tick_region(cache, &mut w.pool, w.flavor)?; - debug!( - pool = %w.address, - new_tick = w.pool.tick, - "extended tick region lazily (parallel path)" - ); - } - v3_refreshed += 1; - pb.inc(1); - } - - // -- Fallback: sequential refresh for pools that failed -- - for addr in &v2_fallback_addrs { - if let Some(amm_ref) = amms.get(addr) { - let mut pool = { - let guard = amm_ref.read().map_err(|_| anyhow!("AMM lock poisoned"))?; - match &*guard { - LocalAMM::UniswapV2(p) => p.clone(), - _ => continue, - } - }; - if let Err(e) = refresh_uniswap_v2_reserves(cache, &mut pool) { - warn!(%addr, error = ?e, "V2 fallback refresh also failed"); - } else { - let mut guard = amm_ref.write().map_err(|_| anyhow!("AMM lock poisoned"))?; - *guard = LocalAMM::UniswapV2(pool); - v2_refreshed += 1; - } - pb.inc(1); - } - } - - for addr in &v3_fallback_addrs { - if let Some(amm_ref) = amms.get(addr) { - let (mut pool, is_pancake) = { - let guard = amm_ref.read().map_err(|_| anyhow!("AMM lock poisoned"))?; - match &*guard { - LocalAMM::UniswapV3(p) => (p.clone(), false), - LocalAMM::PancakeSwapV3(p) => (p.clone(), true), - _ => continue, - } - }; - let fallback_flavor = if is_pancake { - V3Flavor::PancakeSwapV3 - } else { - V3Flavor::UniswapV3 - }; - if let Err(e) = v3_sync::refresh_v3_state(cache, &mut pool, fallback_flavor) { - warn!(%addr, error = ?e, "V3 fallback refresh also failed"); - } else { - if pool.tick_bitmap.is_empty() { - sync_uniswap_v3_ticks(cache, &mut pool, fallback_flavor)?; - full_resyncs += 1; - } else if needs_tick_resync(&pool, pool.tick) { - extend_v3_tick_region(cache, &mut pool, fallback_flavor)?; - } - let mut guard = amm_ref.write().map_err(|_| anyhow!("AMM lock poisoned"))?; - *guard = if is_pancake { - LocalAMM::PancakeSwapV3(pool) - } else { - LocalAMM::UniswapV3(pool) - }; - v3_refreshed += 1; - } - pb.inc(1); - } - } - - // -- Balancer: Sequential refresh (requires EVM call_view) -- - let mut balancer_refreshed = 0u32; - for w in &mut balancer_work { - if let Err(e) = refresh_balancer_pool(cache, &mut w.pool) { - warn!(pool = %w.address, error = ?e, "Balancer refresh failed"); - } else { - balancer_refreshed += 1; - } - pb.inc(1); - } - - // -- Write updated pools back to AMMRef -- - for w in v2_work { - if v2_fallback_addrs.contains(&w.address) { - continue; // Already handled in fallback - } - if let Some(amm_ref) = amms.get(&w.address) { - let mut guard = amm_ref - .write() - .map_err(|_| anyhow!("AMM lock poisoned for {}", w.address))?; - *guard = LocalAMM::UniswapV2(w.pool); - } - } - for w in v3_work { - if v3_fallback_addrs.contains(&w.address) { - continue; // Already handled in fallback - } - if let Some(amm_ref) = amms.get(&w.address) { - let mut guard = amm_ref - .write() - .map_err(|_| anyhow!("AMM lock poisoned for {}", w.address))?; - *guard = match w.flavor { - V3Flavor::UniswapV3 => LocalAMM::UniswapV3(w.pool), - V3Flavor::PancakeSwapV3 => LocalAMM::PancakeSwapV3(w.pool), - V3Flavor::Slipstream => { - // Preserve the original SlipstreamPool and update V3 state - if let LocalAMM::Slipstream(ref mut slip) = *guard { - slip.apply_v3_state(&w.pool); - } - continue; - } - }; - } - } - for w in balancer_work { - if let Some(amm_ref) = amms.get(&w.address) { - let mut guard = amm_ref - .write() - .map_err(|_| anyhow!("AMM lock poisoned for {}", w.address))?; - *guard = LocalAMM::Balancer(w.pool); - } - } - - // -- BalancerV3: Sequential refresh (vault already purged above) -- - let mut balancer_v3_refreshed = 0u32; - for w in &mut balancer_v3_work { - if let Err(e) = balancer_v3_sync::refresh_balancer_v3_pool(cache, &mut w.pool) { - warn!(pool = %w.address, error = ?e, "BalancerV3 refresh failed"); - } else { - balancer_v3_refreshed += 1; - } - pb.inc(1); - } - for w in balancer_v3_work { - if let Some(amm_ref) = amms.get(&w.address) { - let mut guard = amm_ref - .write() - .map_err(|_| anyhow!("AMM lock poisoned for {}", w.address))?; - *guard = LocalAMM::BalancerV3(w.pool); - } - } - - // -- Curve: Sequential refresh via balances(i) view calls -- - let mut curve_refreshed = 0u32; - for w in &mut curve_work { - if let Err(e) = curve_sync::refresh_curve_reserves(cache, &mut w.pool) { - warn!(pool = %w.address, error = ?e, "Curve refresh failed"); - } else { - curve_refreshed += 1; - } - pb.inc(1); - } - for w in curve_work { - if let Some(amm_ref) = amms.get(&w.address) { - let mut guard = amm_ref - .write() - .map_err(|_| anyhow!("AMM lock poisoned for {}", w.address))?; - *guard = LocalAMM::Curve(w.pool); - } - } - - // -- SolidlyV2: refresh reserves via getReserves() view call -- - for (addr, amm_ref) in amms.iter() { - let is_solidly = { - let guard = amm_ref.read().map_err(|_| anyhow!("AMM lock poisoned"))?; - matches!(&*guard, LocalAMM::SolidlyV2(_)) - }; - if is_solidly { - let mut guard = amm_ref - .write() - .map_err(|_| anyhow!("AMM lock poisoned for {}", addr))?; - if let LocalAMM::SolidlyV2(ref mut pool) = *guard { - if let Err(e) = solidly_v2_sync::refresh_solidly_v2_reserves(cache, pool) { - warn!("Failed to refresh SolidlyV2 pool {}: {:?}", addr, e); - } else { - v2_refreshed += 1; - } - } - } - } - - finish_with_message( - &pb, - &format!( - "V2: {} refreshed, V3: {} refreshed ({} fast path, {} full resyncs), Bal: {}, BalV3: {}, Curve: {}", - v2_refreshed, - v3_refreshed, - v3_liquidity_fast_paths, - full_resyncs, - balancer_refreshed, - balancer_v3_refreshed, - curve_refreshed - ), - ); - - phase_writeback_ms = phase6_start.elapsed().as_millis() as u64; - - let total_ms = fn_start.elapsed().as_millis(); - tracing::info!( - v2_refreshed, - v3_refreshed, - v3_liquidity_fast_paths, - balancer_refreshed, - balancer_v3_refreshed, - curve_refreshed, - full_resyncs, - total_ms, - phase_classify_ms, - phase_purge_ms, - phase_fetch_ms, - phase_decode_ms, - phase_bitmap_ms, - phase_tick_ms, - phase_writeback_ms, - "Parallel pool state refresh complete" - ); - - Ok(()) -} - -/// Purge type-specific hot storage slots for the given pool addresses. -/// -/// This selectively purges only the slots that change between blocks: -/// - V2 pools: reserves slot only -/// - V3 pools: slot0 + liquidity (preserves expensive tick data) -/// - PancakeV3: slot0 + PancakeSwap liquidity slot -/// - Other/unknown: full storage purge -/// -/// Returns the number of pools purged. -pub fn purge_amm_hot_slots( - cache: &mut EvmCache, - amms: &HashMap, - pool_addresses: &[Address], -) -> usize { - let mut purged_count = 0usize; - for addr in pool_addresses { - if let Some(amm_ref) = amms.get(addr) { - let guard = amm_ref.read().expect("AMM lock poisoned during purge"); - match &*guard { - LocalAMM::UniswapV2(_) => { - cache.purge_pool_slots(*addr, &[V2_RESERVES_SLOT]); - } - LocalAMM::UniswapV3(_) => { - cache.purge_pool_slots(*addr, &[V3_SLOT0_SLOT, V3_LIQUIDITY_SLOT]); - } - LocalAMM::PancakeSwapV3(_) => { - cache.purge_pool_slots(*addr, &[V3_SLOT0_SLOT, PANCAKE_V3_LIQUIDITY_SLOT]); - } - LocalAMM::SolidlyV2(_) => { - cache.purge_pool_storage(*addr); - } - LocalAMM::Slipstream(_) => { - cache.purge_pool_slots( - *addr, - &[SLIPSTREAM_SLOT0_SLOT, SLIPSTREAM_LIQUIDITY_SLOT], - ); - } - _ => { - cache.purge_pool_storage(*addr); - } - } - drop(guard); - } else { - // Pool not in amms map — purge full storage so lazy RPC - // fetch uses the current pinned block - cache.purge_pool_storage(*addr); - } - purged_count += 1; - } - purged_count -} - -/// Result of hot-state EVM cache injection. -pub struct HotInjectionResult { - /// Number of V2 pools with reserves injected directly (zero RPC). - pub v2_injected: usize, - /// Number of V3 pools with slot0+liquidity injected directly (zero RPC). - pub v3_injected: usize, - /// V3 pools that need tick resync due to liquidity changes. - pub v3_needs_tick_resync: Vec<(Address, TickResyncReason)>, - /// Pools where injection failed (fallback to RPC sync). - pub injection_failures: Vec
, -} - -/// Reason a V3 pool needs tick resync after hot-state injection. -pub enum TickResyncReason { - /// Mint/Burn events detected — resync only the affected tick ranges. - KnownRanges(Vec<(i32, i32)>), - /// Liquidity changed but no Mint/Burn events captured (cold cache or WS gap). - /// Use `incremental_sync_v3_ticks()` with adaptive scan params. - UnknownRanges, -} - -/// Inject hot-state values directly into the EVM cache for pools with fresh WS data. -/// -/// This **replaces** `purge_amm_hot_slots()` for the hot-state sync path. Instead of -/// purging known values and letting revm lazy-fetch them via RPC, we write the values -/// we already know from WebSocket events directly into the CacheDB layer. -/// -/// - **V2 pools**: Pack reserves into slot 8 and inject (zero RPC). -/// - **V3 pools**: Read-modify-write slot0 (patch sqrtPriceX96+tick, preserve observation -/// fields), inject liquidity into slot 4/5 (zero RPC). -/// -/// The `pending_tick_ranges` parameter maps V3 pool addresses to their pending Mint/Burn -/// tick ranges (from `hot_state.peek_pending_tick_changes()`). Pools with pending changes -/// or liquidity changes are flagged for tick resync in the returned `HotInjectionResult`. -pub fn inject_hot_state_to_evm( - cache: &mut EvmCache, - amms: &HashMap, - pool_addresses: &[Address], - pending_tick_ranges: &HashMap>, - observations: &mut SlotObservationTracker, -) -> HotInjectionResult { - let mut result = HotInjectionResult { - v2_injected: 0, - v3_injected: 0, - v3_needs_tick_resync: Vec::new(), - injection_failures: Vec::new(), - }; - - for &addr in pool_addresses { - let Some(amm_ref) = amms.get(&addr) else { - // Pool not in AMM map — fall back to full storage purge - cache.purge_pool_storage(addr); - result.injection_failures.push(addr); - continue; - }; - - let guard = amm_ref.read().expect("AMM lock poisoned during injection"); - match &*guard { - LocalAMM::UniswapV2(pool) => { - let packed = encode_v2_reserves_raw(pool.reserve_0, pool.reserve_1); - if let Err(e) = cache.insert_storage_slot(addr, V2_RESERVES_SLOT, packed) { - warn!(pool = %addr, error = ?e, "V2 reserves injection failed"); - cache.purge_pool_slots(addr, &[V2_RESERVES_SLOT]); - result.injection_failures.push(addr); - } else { - observations.observe( - addr, - V2_RESERVES_SLOT, - packed, - current_observation_time(), - ); - result.v2_injected += 1; - } - } - LocalAMM::UniswapV3(pool) => { - inject_v3_hot_state( - cache, - addr, - pool, - V3_SLOT0_SLOT, - V3_LIQUIDITY_SLOT, - pending_tick_ranges, - observations, - &mut result, - ); - } - LocalAMM::PancakeSwapV3(pool) => { - inject_v3_hot_state( - cache, - addr, - pool, - V3_SLOT0_SLOT, - PANCAKE_V3_LIQUIDITY_SLOT, - pending_tick_ranges, - observations, - &mut result, - ); - } - LocalAMM::Slipstream(slip) => { - // Slipstream uses V3-style storage at shifted slots. - // Build a temporary V3 pool view with just the fields needed for injection. - let v3_view = UniswapV3Pool { - address: slip.address, - token_a: amms::amms::Token::new_with_decimals(slip.token_a, slip.decimals_a), - token_b: amms::amms::Token::new_with_decimals(slip.token_b, slip.decimals_b), - fee: slip.fee, - tick: slip.tick, - tick_spacing: slip.tick_spacing, - liquidity: slip.liquidity, - sqrt_price: slip.sqrt_price, - ticks: HashMap::new(), - tick_bitmap: HashMap::new(), - }; - inject_v3_hot_state( - cache, - addr, - &v3_view, - SLIPSTREAM_SLOT0_SLOT, - SLIPSTREAM_LIQUIDITY_SLOT, - pending_tick_ranges, - observations, - &mut result, - ); - } - _ => { - // Balancer/ERC4626/Curve/SolidlyV2/BalancerV3/UniswapV4 — fall back to full storage purge - cache.purge_pool_storage(addr); - } - } - } - - if result.v2_injected > 0 || result.v3_injected > 0 { - debug!( - v2 = result.v2_injected, - v3 = result.v3_injected, - tick_resync = result.v3_needs_tick_resync.len(), - failures = result.injection_failures.len(), - "Injected hot state into EVM cache" - ); - } - - result -} - -/// Internal helper: inject V3 slot0 + liquidity into EVM cache. -#[allow(clippy::too_many_arguments)] -fn inject_v3_hot_state( - cache: &mut EvmCache, - addr: Address, - pool: &UniswapV3Pool, - slot0_slot: U256, - liquidity_slot: U256, - pending_tick_ranges: &HashMap>, - observations: &mut SlotObservationTracker, - result: &mut HotInjectionResult, -) { - // Read-modify-write slot0: read existing (preserves observation fields), patch price+tick - let slot0_result = cache.read_storage_slot(addr, slot0_slot); - match slot0_result { - Ok(existing_slot0) => { - let patched = encode_v3_slot0_patch(existing_slot0, pool.sqrt_price, pool.tick); - if let Err(e) = cache.insert_storage_slot(addr, slot0_slot, patched) { - warn!(pool = %addr, error = ?e, "V3 slot0 injection failed"); - cache.purge_pool_slots(addr, &[slot0_slot, liquidity_slot]); - result.injection_failures.push(addr); - return; - } - observations.observe(addr, slot0_slot, patched, current_observation_time()); - } - Err(e) => { - // First cycle or cache miss — can't read-modify-write, fall back to purge - debug!(pool = %addr, error = ?e, "V3 slot0 read failed (first cycle?), falling back to purge"); - cache.purge_pool_slots(addr, &[slot0_slot, liquidity_slot]); - result.injection_failures.push(addr); - return; - } - } - - // Inject liquidity directly - let liquidity_value = U256::from(pool.liquidity); - if let Err(e) = cache.insert_storage_slot(addr, liquidity_slot, liquidity_value) { - warn!(pool = %addr, error = ?e, "V3 liquidity injection failed"); - cache.purge_pool_slots(addr, &[slot0_slot, liquidity_slot]); - result.injection_failures.push(addr); - return; - } - - // Check if liquidity changed from last observation - let prev_liquidity = observations.last_value(addr, liquidity_slot); - let liquidity_changed = prev_liquidity.is_some_and(|prev| prev != liquidity_value); - observations.observe( - addr, - liquidity_slot, - liquidity_value, - current_observation_time(), - ); - - result.v3_injected += 1; - - // Determine if tick resync is needed - let has_pending = pending_tick_ranges - .get(&addr) - .is_some_and(|ranges| !ranges.is_empty()); - - if has_pending { - let ranges = pending_tick_ranges[&addr].clone(); - result - .v3_needs_tick_resync - .push((addr, TickResyncReason::KnownRanges(ranges))); - } else if liquidity_changed { - // Liquidity changed but no Mint/Burn events captured — need incremental resync - result - .v3_needs_tick_resync - .push((addr, TickResyncReason::UnknownRanges)); - } - // If liquidity unchanged and no pending tick changes → skip tick resync entirely -} - -/// Selectively purge contract storage using the slot observation tracker. -/// -/// Instead of `cache.purge_contracts_storage()` which deletes ALL slots for a contract, -/// this enumerates known cached slots and only purges those that `should_refetch()` says -/// are likely to have changed. Stable slots (config, immutable addresses, etc.) are skipped, -/// saving RPC calls when revm subsequently reads them. -/// -/// Falls back to full purge if no cached slots are found (first cycle). -/// -/// Returns `(slots_purged, slots_skipped)`. -pub fn smart_purge_contract_storage( - cache: &mut EvmCache, - addr: Address, - tracker: &mut SlotObservationTracker, -) -> (usize, usize) { - let cached_slots = cache.enumerate_contract_slots(addr); - if cached_slots.is_empty() { - // No cached data — fall back to full purge (first cycle) - cache.purge_pool_storage(addr); - return (0, 0); - } - - let mut purged = 0usize; - let mut skipped = 0usize; - - for slot in &cached_slots { - if tracker.should_refetch( - addr, - *slot, - current_observation_time(), - &FreshnessParams::default(), - ) { - cache.purge_pool_slots(addr, &[*slot]); - purged += 1; - } else { - tracker.record_skip(addr, *slot); - skipped += 1; - } - } - - (purged, skipped) -} - -/// Selectively purge multiple contracts using the slot observation tracker. -/// -/// Like `smart_purge_contract_storage` but for a batch of addresses. -/// Returns `(total_purged, total_skipped)`. -pub fn smart_purge_contracts_storage( - cache: &mut EvmCache, - addresses: impl IntoIterator, - tracker: &mut SlotObservationTracker, -) -> (usize, usize) { - let mut total_purged = 0usize; - let mut total_skipped = 0usize; - for addr in addresses { - let (purged, skipped) = smart_purge_contract_storage(cache, addr, tracker); - total_purged += purged; - total_skipped += skipped; - } - (total_purged, total_skipped) -} - -/// Observation-aware full storage purge, replacing the 48h `purge_all_storage()`. -/// -/// Instead of nuking everything, walks each contract's cached slots and only purges -/// those whose staleness exceeds their time-based threshold. Stable slots (never-changed -/// in observation history) are kept, eliminating the 48h RPC spike. -/// -/// Falls back to `purge_all_storage()` if the tracker has insufficient data. -pub fn smart_purge_all_storage( - cache: &mut EvmCache, - tracker: &mut SlotObservationTracker, -) -> (usize, usize) { - // If tracker is very new (< MIN_OBSERVATIONS worth of data), fall back - if tracker.len() < 50 { - let purged = cache.purge_all_storage(); - return (purged, 0); - } - - let all_addresses: Vec
= cache.all_cached_contract_addresses(); - smart_purge_contracts_storage(cache, all_addresses, tracker) -} diff --git a/src/cache_sync/slipstream_sync.rs b/src/cache_sync/slipstream_sync.rs deleted file mode 100644 index 8f5bf51..0000000 --- a/src/cache_sync/slipstream_sync.rs +++ /dev/null @@ -1,84 +0,0 @@ -use super::*; -use crate::slipstream_pool::SlipstreamPool; - -sol!( - #[sol(rpc)] - contract ISlipstreamPool { - function token0() external view returns (address); - function token1() external view returns (address); - function slot0() external view returns (uint160 sqrtPriceX96, int24 tick, uint16 observationIndex, uint16 observationCardinality, uint16 observationCardinalityNext, bool unlocked); - function liquidity() external view returns (uint128); - function fee() external view returns (uint24); - function tickSpacing() external view returns (int24); - } -); - -/// Initialize a Slipstream (Aerodrome/Velodrome CL) pool from the EVM cache. -/// -/// Reads slot0, liquidity, fee, and token metadata. Tick data is deferred -/// to the V3 tick resync phase (Slipstream uses the same storage layout). -#[instrument(skip(cache), fields(pool = %address))] -pub async fn init_slipstream_from_cache( - cache: &mut EvmCache, - address: Address, - config_tick_spacing: i32, -) -> Result { - cache.ensure_account(address).await?; - - let token0 = call_view(cache, address, ISlipstreamPool::token0Call {})?; - let token1 = call_view(cache, address, ISlipstreamPool::token1Call {})?; - - // Read fee from contract (Slipstream fee is dynamic, not derived from tick_spacing) - let fee: u32 = match call_view(cache, address, ISlipstreamPool::feeCall {}) { - Ok(f) => f.to::(), - Err(_) => { - warn!(pool = %address, "Failed to read Slipstream fee, defaulting to 3000"); - 3000 - } - }; - - // Read tick_spacing from contract (overrides config if available) - let tick_spacing: i32 = match call_view(cache, address, ISlipstreamPool::tickSpacingCall {}) { - Ok(ts) => ts.unchecked_into(), - Err(_) => config_tick_spacing, - }; - - // Read slot0 for current tick and sqrt_price - // Slipstream CL pools have shifted storage: slot0 at slot 6, liquidity at slot 17 - cache.purge_pool_slots(address, &[SLIPSTREAM_SLOT0_SLOT, SLIPSTREAM_LIQUIDITY_SLOT]); - let slot0 = call_view(cache, address, ISlipstreamPool::slot0Call {})?; - let liquidity_result = call_view(cache, address, ISlipstreamPool::liquidityCall {})?; - - let sqrt_price = U256::from(slot0.sqrtPriceX96); - let tick: i32 = slot0.tick.unchecked_into(); - let liquidity: u128 = liquidity_result; - - let dec0 = cache.erc20_decimals(token0).unwrap_or(18); - let dec1 = cache.erc20_decimals(token1).unwrap_or(18); - - debug!( - pool = %address, - token0 = %token0, - token1 = %token1, - tick, - liquidity, - fee, - tick_spacing, - "Slipstream pool initialized (tick data deferred)" - ); - - Ok(SlipstreamPool { - address, - token_a: token0, - token_b: token1, - tick_spacing, - tick, - sqrt_price, - liquidity, - fee, - ticks: HashMap::new(), - tick_bitmap: HashMap::new(), - decimals_a: dec0, - decimals_b: dec1, - }) -} diff --git a/src/cache_sync/solidly_v2_sync.rs b/src/cache_sync/solidly_v2_sync.rs deleted file mode 100644 index 39dee82..0000000 --- a/src/cache_sync/solidly_v2_sync.rs +++ /dev/null @@ -1,80 +0,0 @@ -use super::*; -use crate::solidly_v2_pool::SolidlyV2Pool; - -sol!( - #[sol(rpc)] - contract ISolidlyV2Pool { - function token0() external view returns (address); - function token1() external view returns (address); - function getReserves() external view returns (uint256 reserve0, uint256 reserve1, uint256 blockTimestampLast); - function stable() external view returns (bool); - } -); - -sol!( - #[sol(rpc)] - contract IERC20Decimals { - function decimals() external view returns (uint8); - } -); - -/// Initialize a SolidlyV2 pool by reading on-chain state from the EVM cache. -/// -/// Reads token0/token1, reserves, and decimal metadata from the pool contract. -#[instrument(skip(cache), fields(pool = %address))] -pub async fn init_solidly_v2_from_cache( - cache: &mut EvmCache, - address: Address, - stable: bool, - factory: Address, - fee: u32, -) -> Result { - cache.ensure_account(address).await?; - - let token0 = call_view(cache, address, ISolidlyV2Pool::token0Call {})?; - let token1 = call_view(cache, address, ISolidlyV2Pool::token1Call {})?; - - // Purge and re-fetch reserves - cache.purge_pool_storage(address); - let reserves = call_view(cache, address, ISolidlyV2Pool::getReservesCall {})?; - - let dec0 = cache.erc20_decimals(token0).unwrap_or(18); - let dec1 = cache.erc20_decimals(token1).unwrap_or(18); - - debug!( - pool = %address, - token0 = %token0, - token1 = %token1, - reserve_0 = ?reserves.reserve0, - reserve_1 = ?reserves.reserve1, - stable, - dec0, - dec1, - "SolidlyV2 pool initialized" - ); - - Ok(SolidlyV2Pool { - address, - token_a: token0, - token_b: token1, - stable, - factory, - reserve_0: reserves.reserve0.try_into().unwrap_or(u128::MAX), - reserve_1: reserves.reserve1.try_into().unwrap_or(u128::MAX), - fee, - decimals_0: dec0, - decimals_1: dec1, - }) -} - -/// Refresh the reserves of a SolidlyV2 pool for per-cycle freshness. -#[instrument(skip(cache), fields(pool = %pool.address))] -pub fn refresh_solidly_v2_reserves(cache: &mut EvmCache, pool: &mut SolidlyV2Pool) -> Result<()> { - cache.purge_pool_storage(pool.address); - let reserves = call_view(cache, pool.address, ISolidlyV2Pool::getReservesCall {})?; - - pool.reserve_0 = reserves.reserve0.try_into().unwrap_or(u128::MAX); - pool.reserve_1 = reserves.reserve1.try_into().unwrap_or(u128::MAX); - - Ok(()) -} diff --git a/src/cache_sync/v2_sync.rs b/src/cache_sync/v2_sync.rs deleted file mode 100644 index eac1173..0000000 --- a/src/cache_sync/v2_sync.rs +++ /dev/null @@ -1,130 +0,0 @@ -use super::*; - -#[instrument(skip(cache), fields(pool = %address))] -pub async fn init_uniswap_v2_from_cache( - cache: &mut EvmCache, - address: Address, - fee: usize, -) -> Result { - let fn_start = Instant::now(); - - let t0 = Instant::now(); - cache.ensure_account(address).await?; - let ensure_ms = t0.elapsed().as_millis(); - - // Try to load immutable metadata from cache (token0, token1) - let t1 = Instant::now(); - let (token0, token1, cached_timestamp) = - if let Some(metadata) = cache.immutable_cache().get_v2_pool(address) { - debug!("using cached V2 pool metadata"); - ( - metadata.token0, - metadata.token1, - Some(metadata.last_block_timestamp), - ) - } else { - // Fetch immutable data from RPC - let token0 = call_view(cache, address, IUniswapV2Pair::token0Call {})?; - let token1 = call_view(cache, address, IUniswapV2Pair::token1Call {})?; - debug!("fetched V2 pool immutable metadata from RPC"); - (token0, token1, None) - }; - let metadata_ms = t1.elapsed().as_millis(); - - // Purge all V2 pool storage to ensure fresh reserve fetch. - // V2 pools have ~12 storage slots total, so full purge is nearly zero-cost - // and eliminates any risk of stale non-reserve state affecting EVM execution. - cache.purge_pool_storage(address); - - // Fetch fresh reserves from RPC (storage was purged above if it existed) - let t2 = Instant::now(); - let reserves = call_view(cache, address, IUniswapV2Pair::getReservesCall {})?; - let reserves_ms = t2.elapsed().as_millis(); - let fresh_timestamp = reserves.blockTimestampLast; - - // Log if the timestamp changed (indicates reserves were stale) - if let Some(cached_ts) = cached_timestamp - && cached_ts != fresh_timestamp - { - debug!( - pool = %address, - cached_timestamp = cached_ts, - fresh_timestamp = fresh_timestamp, - "V2 pool reserves were stale (timestamp changed)" - ); - } - - // Update immutable cache with fresh timestamp for future validation - cache.immutable_cache_mut().set_v2_pool( - address, - V2PoolMetadata { - token0, - token1, - last_block_timestamp: fresh_timestamp, - }, - ); - - // Inject immutable metadata (token0, token1) into EVM storage for subsequent calls - if let Some(metadata) = cache.immutable_cache().get_v2_pool(address) - && let Err(e) = cache.inject_v2_pool_metadata(address, &metadata) - { - warn!( - pool = %address, - error = %e, - "failed to inject V2 pool metadata into storage cache" - ); - } - - let dec0 = cache.erc20_decimals(token0).unwrap_or(18); - let dec1 = cache.erc20_decimals(token1).unwrap_or(18); - - let total_ms = fn_start.elapsed().as_millis(); - debug!( - pool = %address, - ensure_ms, - metadata_ms, - reserves_ms, - total_ms, - "V2 pool init breakdown" - ); - - Ok(UniswapV2Pool { - address, - token_a: Token::new_with_decimals(token0, dec0), - token_b: Token::new_with_decimals(token1, dec1), - reserve_0: reserves.reserve0.to::(), - reserve_1: reserves.reserve1.to::(), - fee, - }) -} - -/// Refresh the reserves of a UniswapV2 pool for per-cycle freshness. -/// -/// This purges the reserves slot and re-fetches fresh reserves from RPC, -/// then updates the pool's reserve_0 and reserve_1 fields. -#[instrument(skip(cache), fields(pool = %pool.address))] -pub fn refresh_uniswap_v2_reserves(cache: &mut EvmCache, pool: &mut UniswapV2Pool) -> Result<()> { - // Purge all V2 pool storage to force a fresh RPC read. - // V2 pools have ~12 slots total, so full purge is nearly zero-cost. - cache.purge_pool_storage(pool.address); - - let reserves = call_view(cache, pool.address, IUniswapV2Pair::getReservesCall {})?; - - let old_r0 = pool.reserve_0; - let old_r1 = pool.reserve_1; - - pool.reserve_0 = reserves.reserve0.to::(); - pool.reserve_1 = reserves.reserve1.to::(); - - if old_r0 != pool.reserve_0 || old_r1 != pool.reserve_1 { - debug!( - old_r0, - new_r0 = pool.reserve_0, - old_r1, - new_r1 = pool.reserve_1, - "V2 reserves changed" - ); - } - - Ok(()) -} diff --git a/src/cache_sync/v3_bitmap.rs b/src/cache_sync/v3_bitmap.rs deleted file mode 100644 index 3550910..0000000 --- a/src/cache_sync/v3_bitmap.rs +++ /dev/null @@ -1,584 +0,0 @@ -use super::*; -use crate::tuning::{ProtocolAddresses, SyncSpeedMode, sync_speed_mode}; - -/// Adaptive scan parameters based on pool liquidity depth. -/// -/// High-liquidity pools need fewer words scanned because bounded swaps cross -/// fewer ticks. Tick spacing affects how much price range each word covers: -/// - spacing=1: 1 word = 256 ticks (~2.56% price range) -/// - spacing=10: 1 word = 2560 ticks (~25.6% price range) -/// - spacing=60: 1 word = 15360 ticks (~153% price range) -pub(crate) struct AdaptiveScanParams { - /// Maximum words to scan in each direction from current tick. - pub(crate) max_scan_words: i32, - /// Consecutive empty bitmap words before stopping the scan. - pub(crate) empty_word_threshold: usize, -} - -/// Compute scan parameters based on pool liquidity and tick spacing. -/// -/// High-liquidity pools need fewer words because swaps cross fewer ticks. -/// The thresholds are conservative (3-10x more coverage than estimated -/// ticks-crossed for a $100K swap at each liquidity level). -pub(crate) fn compute_adaptive_scan_params( - liquidity: u128, - tick_spacing: i32, -) -> AdaptiveScanParams { - let narrow_spacing = tick_spacing < 10; - - // Speed multiplier: reduces scan range for slower modes to save RPC calls. - let multiplier = match sync_speed_mode() { - SyncSpeedMode::Fast => 1.0, - SyncSpeedMode::Normal => 0.75, - SyncSpeedMode::Slow => 0.5, - SyncSpeedMode::XSlow => 0.15, - }; - - let scale = |words: i32| -> i32 { ((words as f64) * multiplier).ceil().max(3.0) as i32 }; - - if liquidity >= 1_000_000_000_000_000_000 { - // Deep liquidity (>=1e18): very few ticks crossed per swap - AdaptiveScanParams { - max_scan_words: scale(if narrow_spacing { 15 } else { 8 }), - empty_word_threshold: 3, - } - } else if liquidity >= 1_000_000_000_000_000 { - // Moderate liquidity (>=1e15) - AdaptiveScanParams { - max_scan_words: scale(if narrow_spacing { 30 } else { 15 }), - empty_word_threshold: 4, - } - } else if liquidity >= 1_000_000_000_000 { - // Thin liquidity (>=1e12) - AdaptiveScanParams { - max_scan_words: scale(if narrow_spacing { 80 } else { 40 }), - empty_word_threshold: 5, - } - } else { - // Very thin liquidity: use full scan limits - AdaptiveScanParams { - max_scan_words: scale(if narrow_spacing { - max_scan_radius() - } else { - 200 - }), - empty_word_threshold: 5, - } - } -} - -/// Whether a V3-style pool is Uniswap V3, PancakeSwap V3, or Slipstream CL. -/// Determines which storage slot constants to use. -#[derive(Clone, Copy, Debug, PartialEq)] -pub enum V3Flavor { - UniswapV3, - PancakeSwapV3, - /// Aerodrome/Velodrome Slipstream CL pools — shifted storage layout - /// (slot0=6, liquidity=17, ticks=18, tickBitmap=19). - Slipstream, -} - -impl V3Flavor { - pub fn slot0_slot(&self) -> U256 { - match self { - V3Flavor::UniswapV3 | V3Flavor::PancakeSwapV3 => V3_SLOT0_SLOT, - V3Flavor::Slipstream => SLIPSTREAM_SLOT0_SLOT, - } - } - - pub fn liquidity_slot(&self) -> U256 { - match self { - V3Flavor::UniswapV3 => V3_LIQUIDITY_SLOT, - V3Flavor::PancakeSwapV3 => PANCAKE_V3_LIQUIDITY_SLOT, - V3Flavor::Slipstream => SLIPSTREAM_LIQUIDITY_SLOT, - } - } - - pub fn tick_bitmap_base_slot(&self) -> U256 { - match self { - V3Flavor::UniswapV3 => V3_TICK_BITMAP_BASE_SLOT, - V3Flavor::PancakeSwapV3 => PANCAKE_V3_TICK_BITMAP_BASE_SLOT, - V3Flavor::Slipstream => SLIPSTREAM_TICK_BITMAP_BASE_SLOT, - } - } - - pub fn ticks_base_slot(&self) -> U256 { - match self { - V3Flavor::UniswapV3 => V3_TICKS_BASE_SLOT, - V3Flavor::PancakeSwapV3 => PANCAKE_V3_TICKS_BASE_SLOT, - V3Flavor::Slipstream => SLIPSTREAM_TICKS_BASE_SLOT, - } - } - - pub(crate) fn tick_bitmap_key(&self, word: i16) -> U256 { - match self { - V3Flavor::UniswapV3 => v3_tick_bitmap_storage_key(word), - V3Flavor::PancakeSwapV3 => { - v3_tick_bitmap_storage_key_with_base(word, PANCAKE_V3_TICK_BITMAP_BASE_SLOT) - } - V3Flavor::Slipstream => { - v3_tick_bitmap_storage_key_with_base(word, SLIPSTREAM_TICK_BITMAP_BASE_SLOT) - } - } - } - - pub(crate) fn tick_info_keys(&self, tick: i32) -> [U256; 4] { - match self { - V3Flavor::UniswapV3 => v3_tick_info_storage_keys(tick), - V3Flavor::PancakeSwapV3 => { - v3_tick_info_storage_keys_with_base(tick, PANCAKE_V3_TICKS_BASE_SLOT) - } - V3Flavor::Slipstream => { - v3_tick_info_storage_keys_with_base(tick, SLIPSTREAM_TICKS_BASE_SLOT) - } - } - } -} - -/// Build a factory-address → V3Flavor lookup map from [`ProtocolAddresses`]. -/// Used during auto-loading to distinguish UniswapV3 vs PancakeSwapV3 vs Slipstream -/// pools that share the same Swap event signature. -pub fn build_v3_factory_map( - addrs: &ProtocolAddresses, -) -> std::collections::HashMap { - let mut map = std::collections::HashMap::new(); - if let Some(addr) = addrs.uniswap_v3_factory { - map.insert(addr, V3Flavor::UniswapV3); - } - if let Some(addr) = addrs.pancake_v3_factory { - map.insert(addr, V3Flavor::PancakeSwapV3); - } - if let Some(addr) = addrs.slipstream_factory { - map.insert(addr, V3Flavor::Slipstream); - } - map -} - -/// Check if a V3 pool needs a full tick resync based on how far the tick has moved. -/// -/// Returns true if the tick has moved to a different bitmap word since last sync, -/// which means we might need to load new tick data. -pub fn needs_tick_resync(pool: &UniswapV3Pool, new_tick: i32) -> bool { - if pool.tick_spacing == 0 { - return false; - } - - let old_word = tick_to_word(pool.tick, pool.tick_spacing); - let new_word = tick_to_word(new_tick, pool.tick_spacing); - - // If we've moved to a different word, check if we have that word's bitmap - if old_word != new_word { - // Check if the new word is already in our bitmap - !pool.tick_bitmap.contains_key(&(new_word as i16)) - } else { - false - } -} - -pub(crate) fn div_floor_i32(a: i32, b: i32) -> i32 { - let mut q = a / b; - if (a ^ b) < 0 && a % b != 0 { - q -= 1; - } - q -} - -pub(crate) fn tick_to_word(tick: i32, tick_spacing: i32) -> i32 { - let compressed = div_floor_i32(tick, tick_spacing); - compressed.div_euclid(256) -} - -/// Extract initialized tick indices from a single bitmap word. -/// -/// This is a per-word version of `extract_initialized_ticks_from_bitmap`, -/// used during incremental resync to compare individual words. -pub(crate) fn extract_ticks_from_bitmap_word( - word_pos: i32, - bitmap: U256, - tick_spacing: i32, -) -> Vec { - let mut ticks = Vec::new(); - if bitmap == U256::ZERO { - return ticks; - } - let limbs = bitmap.as_limbs(); - for (limb_idx, &limb) in limbs.iter().enumerate() { - if limb == 0 { - continue; - } - let base_bit = (limb_idx * 64) as i32; - let mut remaining = limb; - while remaining != 0 { - let bit_in_limb = remaining.trailing_zeros() as i32; - remaining &= remaining - 1; - let bit = base_bit + bit_in_limb; - let tick_index = (word_pos * 256 + bit) * tick_spacing; - ticks.push(tick_index); - } - } - ticks -} - -/// Extract initialized tick indices from the tick bitmap. -/// Uses optimized bit iteration - O(set bits) instead of O(256) per word. -pub(crate) fn extract_initialized_ticks_from_bitmap(pool: &UniswapV3Pool) -> Vec { - let mut initialized_ticks = Vec::new(); - for (word_pos, bitmap) in pool - .tick_bitmap - .iter() - .filter(|(_, bitmap)| **bitmap != U256::ZERO) - { - let word_pos = *word_pos as i32; - // Iterate over the 4 u64 limbs (64 bits each = 256 total) - let limbs = bitmap.as_limbs(); - for (limb_idx, &limb) in limbs.iter().enumerate() { - if limb == 0 { - continue; - } - let base_bit = (limb_idx * 64) as i32; - let mut remaining = limb; - while remaining != 0 { - // Find position of lowest set bit - let bit_in_limb = remaining.trailing_zeros() as i32; - // Clear the lowest set bit - remaining &= remaining - 1; - let bit = base_bit + bit_in_limb; - let tick_index = (word_pos * 256 + bit) * pool.tick_spacing; - initialized_ticks.push(tick_index); - } - } - } - initialized_ticks -} - -#[cfg(test)] -mod tests { - use super::*; - - fn dummy_token() -> Token { - Token::new_with_decimals(Address::ZERO, 18) - } - - #[test] - fn test_tick_to_word() { - // tick_spacing = 1 - assert_eq!(tick_to_word(0, 1), 0); - assert_eq!(tick_to_word(255, 1), 0); - assert_eq!(tick_to_word(256, 1), 1); - assert_eq!(tick_to_word(-1, 1), -1); - assert_eq!(tick_to_word(-256, 1), -1); - assert_eq!(tick_to_word(-257, 1), -2); - - // tick_spacing = 60 - assert_eq!(tick_to_word(0, 60), 0); - assert_eq!(tick_to_word(60 * 255, 60), 0); - assert_eq!(tick_to_word(60 * 256, 60), 1); - assert_eq!(tick_to_word(-60, 60), -1); - } - - #[test] - fn test_tick_to_word_boundaries() { - // Test at MIN_TICK and MAX_TICK - let min_word_ts1 = tick_to_word(MIN_TICK, 1); - let max_word_ts1 = tick_to_word(MAX_TICK, 1); - - // With tick_spacing=1, the range should be about -3466 to 3465 words - assert!(min_word_ts1 < 0); - assert!(max_word_ts1 > 0); - - // The total word range for tick_spacing=1 should be ~6931 - let word_range = max_word_ts1 - min_word_ts1 + 1; - assert!(word_range > 6000 && word_range < 7000); - } - - #[test] - fn test_needs_tick_resync_same_word() { - let mut pool = UniswapV3Pool { - address: Address::ZERO, - token_a: dummy_token(), - token_b: dummy_token(), - fee: 3000, - tick_spacing: 60, - sqrt_price: U256::ZERO, - tick: 1000, - liquidity: 0, - tick_bitmap: HashMap::new(), - ticks: HashMap::new(), - }; - - // Add the current word to the bitmap - let current_word = tick_to_word(1000, 60); - pool.tick_bitmap.insert(current_word as i16, U256::ZERO); - - // Small tick movement within the same word - no resync needed - assert!(!needs_tick_resync(&pool, 1001)); - assert!(!needs_tick_resync(&pool, 999)); - } - - #[test] - fn test_needs_tick_resync_different_word_not_cached() { - let mut pool = UniswapV3Pool { - address: Address::ZERO, - token_a: dummy_token(), - token_b: dummy_token(), - fee: 3000, - tick_spacing: 60, - sqrt_price: U256::ZERO, - tick: 0, - liquidity: 0, - tick_bitmap: HashMap::new(), - ticks: HashMap::new(), - }; - - // Add only word 0 to the bitmap - pool.tick_bitmap.insert(0, U256::ZERO); - - // Large tick movement to a different word not in cache - needs resync - let new_tick = 60 * 256 + 1; // This is in word 1 - assert!(needs_tick_resync(&pool, new_tick)); - } - - #[test] - fn test_needs_tick_resync_different_word_cached() { - let mut pool = UniswapV3Pool { - address: Address::ZERO, - token_a: dummy_token(), - token_b: dummy_token(), - fee: 3000, - tick_spacing: 60, - sqrt_price: U256::ZERO, - tick: 0, - liquidity: 0, - tick_bitmap: HashMap::new(), - ticks: HashMap::new(), - }; - - // Add both word 0 and word 1 to the bitmap - pool.tick_bitmap.insert(0, U256::ZERO); - pool.tick_bitmap.insert(1, U256::ZERO); - - // Large tick movement to a different word but it's cached - no resync needed - let new_tick = 60 * 256 + 1; // This is in word 1 - assert!(!needs_tick_resync(&pool, new_tick)); - } - - #[test] - fn test_div_floor_i32() { - assert_eq!(div_floor_i32(7, 3), 2); - assert_eq!(div_floor_i32(-7, 3), -3); - assert_eq!(div_floor_i32(7, -3), -3); - assert_eq!(div_floor_i32(-7, -3), 2); - assert_eq!(div_floor_i32(6, 3), 2); - assert_eq!(div_floor_i32(-6, 3), -2); - } - - #[test] - fn test_extract_ticks_from_bitmap_word_empty() { - let ticks = extract_ticks_from_bitmap_word(0, U256::ZERO, 60); - assert!(ticks.is_empty()); - } - - #[test] - fn test_extract_ticks_from_bitmap_word_single_bit() { - // Bit 0 set in word 0, tick_spacing=60 -> tick = 0*256*60 + 0*60 = 0 - let bitmap = U256::from(1u64); - let ticks = extract_ticks_from_bitmap_word(0, bitmap, 60); - assert_eq!(ticks, vec![0]); - } - - #[test] - fn test_extract_ticks_from_bitmap_word_multiple_bits() { - // Bits 0 and 3 set in word 0, tick_spacing=1 - let bitmap = U256::from(0b1001u64); // bits 0 and 3 - let ticks = extract_ticks_from_bitmap_word(0, bitmap, 1); - assert_eq!(ticks, vec![0, 3]); - } - - #[test] - fn test_extract_ticks_from_bitmap_word_negative_word() { - // Bit 0 in word -1, tick_spacing=1 -> tick = -1*256 + 0 = -256 - let bitmap = U256::from(1u64); - let ticks = extract_ticks_from_bitmap_word(-1, bitmap, 1); - assert_eq!(ticks, vec![-256]); - } - - #[test] - fn test_extract_ticks_from_bitmap_word_with_spacing() { - // Bit 5 in word 2, tick_spacing=10 -> tick = (2*256 + 5) * 10 = 5170 - let bitmap = U256::from(1u64 << 5); - let ticks = extract_ticks_from_bitmap_word(2, bitmap, 10); - assert_eq!(ticks, vec![5170]); - } - - #[test] - fn test_extract_ticks_from_bitmap_word_matches_pool_extractor() { - // Verify our per-word function gives same results as the pool-level extractor - let tick_spacing = 60; - - let mut tick_bitmap: HashMap = HashMap::new(); - let bitmap_word0 = U256::from(0xFF00u64); - let bitmap_word1 = U256::from(0x01u64); - tick_bitmap.insert(0, bitmap_word0); - tick_bitmap.insert(1, bitmap_word1); - - let pool = UniswapV3Pool { - address: Address::ZERO, - token_a: dummy_token(), - token_b: dummy_token(), - fee: 3000, - tick_spacing, - sqrt_price: U256::ZERO, - tick: 0, - liquidity: 0, - tick_bitmap: tick_bitmap.clone(), - ticks: HashMap::new(), - }; - - // Get ticks from the pool-level extractor - let mut pool_ticks = extract_initialized_ticks_from_bitmap(&pool); - pool_ticks.sort(); - - // Get ticks from our per-word function - let mut word_ticks: Vec = Vec::new(); - word_ticks.extend(extract_ticks_from_bitmap_word( - 0, - bitmap_word0, - tick_spacing, - )); - word_ticks.extend(extract_ticks_from_bitmap_word( - 1, - bitmap_word1, - tick_spacing, - )); - word_ticks.sort(); - - assert_eq!( - pool_ticks, word_ticks, - "per-word extractor should match pool extractor" - ); - } - - // --- AdaptiveScanParams tests --- - - /// Set speed to Fast so adaptive scan tests use base (unmultiplied) values. - fn set_fast_mode() { - crate::tuning::set_sync_speed_mode(SyncSpeedMode::Fast); - } - - #[test] - fn test_adaptive_scan_deep_liquidity_narrow_spacing() { - set_fast_mode(); - let params = compute_adaptive_scan_params(1_000_000_000_000_000_000, 1); - assert_eq!(params.max_scan_words, 15); - assert_eq!(params.empty_word_threshold, 3); - } - - #[test] - fn test_adaptive_scan_deep_liquidity_wide_spacing() { - set_fast_mode(); - let params = compute_adaptive_scan_params(1_000_000_000_000_000_000, 60); - assert_eq!(params.max_scan_words, 8); - assert_eq!(params.empty_word_threshold, 3); - } - - #[test] - fn test_adaptive_scan_moderate_liquidity_narrow_spacing() { - set_fast_mode(); - let params = compute_adaptive_scan_params(1_000_000_000_000_000, 1); - assert_eq!(params.max_scan_words, 30); - assert_eq!(params.empty_word_threshold, 4); - } - - #[test] - fn test_adaptive_scan_moderate_liquidity_wide_spacing() { - set_fast_mode(); - let params = compute_adaptive_scan_params(1_000_000_000_000_000, 10); - assert_eq!(params.max_scan_words, 15); - assert_eq!(params.empty_word_threshold, 4); - } - - #[test] - fn test_adaptive_scan_thin_liquidity() { - set_fast_mode(); - let params = compute_adaptive_scan_params(1_000_000_000_000, 1); - assert_eq!(params.max_scan_words, 80); - assert_eq!(params.empty_word_threshold, 5); - - let params_wide = compute_adaptive_scan_params(1_000_000_000_000, 60); - assert_eq!(params_wide.max_scan_words, 40); - assert_eq!(params_wide.empty_word_threshold, 5); - } - - #[test] - fn test_adaptive_scan_very_thin_liquidity() { - set_fast_mode(); - let params = compute_adaptive_scan_params(999_999_999_999, 1); - assert_eq!(params.max_scan_words, max_scan_radius()); - assert_eq!(params.empty_word_threshold, 5); - - let params_wide = compute_adaptive_scan_params(0, 60); - assert_eq!(params_wide.max_scan_words, 200); - assert_eq!(params_wide.empty_word_threshold, 5); - } - - #[test] - fn test_adaptive_scan_boundary_values() { - set_fast_mode(); - // Exactly at tier boundaries - let deep = compute_adaptive_scan_params(1_000_000_000_000_000_000, 1); - let just_below_deep = compute_adaptive_scan_params(999_999_999_999_999_999, 1); - assert_eq!(deep.max_scan_words, 15); - assert_eq!(just_below_deep.max_scan_words, 30); - - let moderate = compute_adaptive_scan_params(1_000_000_000_000_000, 1); - let just_below_moderate = compute_adaptive_scan_params(999_999_999_999_999, 1); - assert_eq!(moderate.max_scan_words, 30); - assert_eq!(just_below_moderate.max_scan_words, 80); - - let thin = compute_adaptive_scan_params(1_000_000_000_000, 1); - let just_below_thin = compute_adaptive_scan_params(999_999_999_999, 1); - assert_eq!(thin.max_scan_words, 80); - assert_eq!(just_below_thin.max_scan_words, max_scan_radius()); - } - - #[test] - fn test_adaptive_scan_speed_mode_scaling() { - // Verify that speed mode affects scan params. - // NOTE: this test mutates global state. set_fast_mode() at end to - // avoid poisoning other parallel tests that also call set_fast_mode(). - set_fast_mode(); - let fast = compute_adaptive_scan_params(1_000_000_000_000_000, 1); - - crate::tuning::set_sync_speed_mode(SyncSpeedMode::XSlow); - let xslow = compute_adaptive_scan_params(1_000_000_000_000_000, 1); - - // Restore to Fast (all other tests in this module assume Fast). - set_fast_mode(); - - assert!( - fast.max_scan_words > xslow.max_scan_words, - "fast ({}) should scan more words than xslow ({})", - fast.max_scan_words, - xslow.max_scan_words - ); - } - - #[test] - fn test_adaptive_scan_spacing_threshold() { - set_fast_mode(); - // tick_spacing=9 is narrow, tick_spacing=10 is wide - let narrow = compute_adaptive_scan_params(1_000_000_000_000_000_000, 9); - let wide = compute_adaptive_scan_params(1_000_000_000_000_000_000, 10); - assert_eq!(narrow.max_scan_words, 15); // narrow path - assert_eq!(wide.max_scan_words, 8); // wide path - } - - #[test] - fn test_adaptive_scan_very_high_liquidity() { - set_fast_mode(); - // u128::MAX should still hit deep tier - let params = compute_adaptive_scan_params(u128::MAX, 1); - assert_eq!(params.max_scan_words, 15); - assert_eq!(params.empty_word_threshold, 3); - } -} diff --git a/src/cache_sync/v3_sync.rs b/src/cache_sync/v3_sync.rs deleted file mode 100644 index dbb6477..0000000 --- a/src/cache_sync/v3_sync.rs +++ /dev/null @@ -1,2411 +0,0 @@ -use super::compat::TickInfo as CacheTickInfo; -use super::v3_bitmap::{ - V3Flavor, compute_adaptive_scan_params, extract_initialized_ticks_from_bitmap, - extract_ticks_from_bitmap_word, needs_tick_resync, tick_to_word, -}; -use super::*; - -/// Result of V3 phase 1 initialization. -/// -/// When using the two-phase V3 init flow, phase 1 determines whether the pool -/// can be fully initialized from cache or needs a full bitmap resync. If resync -/// is needed, the caller can batch-prefetch bitmap storage slots in parallel -/// before completing the resync in phase 2. -pub enum V3InitPhase1Result { - /// Pool fully initialized (snapshot hit or preloaded storage). - Complete(UniswapV3Pool), - /// Pool needs full bitmap resync. Contains partially-built pool and flavor. - NeedsResync { - pool: UniswapV3Pool, - flavor: V3Flavor, - }, - /// Pool needs incremental resync (snapshot validation failed). - /// Contains the partially-built pool, flavor, and old bitmap/ticks for comparison. - NeedsIncrementalResync { - pool: UniswapV3Pool, - flavor: V3Flavor, - old_bitmap: HashMap, - old_ticks: HashMap, - }, -} - -/// Target for V3 bitmap prefetch -- describes one pool that needs parallel bitmap fetching. -pub struct V3BitmapPrefetchTarget { - pub address: Address, - pub flavor: V3Flavor, - pub tick_spacing: i32, - pub center_tick: i32, - pub max_scan_words: i32, - /// Consecutive empty bitmap words before stopping the scan (mirrors sync logic). - pub empty_word_threshold: usize, -} - -pub type V3IncrementalPrefetchTarget<'a> = - (Address, V3Flavor, i32, i32, u128, &'a HashMap); - -#[instrument(skip(cache), fields(pool = %address))] -pub async fn init_uniswap_v3_from_cache( - cache: &mut EvmCache, - address: Address, -) -> Result { - match init_v3_from_cache(cache, address, V3Flavor::UniswapV3).await? { - V3InitInternalResult::Complete(pool) => Ok(pool), - V3InitInternalResult::DeferredResync(mut pool) => { - sync_uniswap_v3_ticks(cache, &mut pool, V3Flavor::UniswapV3)?; - save_v3_tick_snapshot(cache, &pool); - Ok(pool) - } - V3InitInternalResult::DeferredIncrementalResync(mut pool, old_bitmap, old_ticks) => { - let flavor = V3Flavor::UniswapV3; - incremental_sync_v3_ticks(cache, &mut pool, &old_bitmap, &old_ticks, flavor, false)?; - inject_v3_tick_data(cache, address, &pool, flavor); - Ok(pool) - } - } -} - -pub async fn init_pancakeswap_v3_from_cache( - cache: &mut EvmCache, - address: Address, -) -> Result { - match init_v3_from_cache(cache, address, V3Flavor::PancakeSwapV3).await? { - V3InitInternalResult::Complete(pool) => Ok(pool), - V3InitInternalResult::DeferredResync(mut pool) => { - sync_uniswap_v3_ticks(cache, &mut pool, V3Flavor::PancakeSwapV3)?; - save_v3_tick_snapshot(cache, &pool); - Ok(pool) - } - V3InitInternalResult::DeferredIncrementalResync(mut pool, old_bitmap, old_ticks) => { - let flavor = V3Flavor::PancakeSwapV3; - incremental_sync_v3_ticks(cache, &mut pool, &old_bitmap, &old_ticks, flavor, false)?; - inject_v3_tick_data(cache, address, &pool, flavor); - Ok(pool) - } - } -} - -/// Phase 1 of V3 initialization: determines cache status and returns NeedsResync -/// instead of performing the expensive tick bitmap scan inline. -/// -/// Call this for each V3 pool, then batch-prefetch bitmap storage for all pools -/// that return NeedsResync, then call `sync_uniswap_v3_ticks` + `save_v3_tick_snapshot` -/// to complete them. -pub async fn init_v3_phase1( - cache: &mut EvmCache, - address: Address, - flavor: V3Flavor, -) -> Result { - match init_v3_from_cache(cache, address, flavor).await? { - V3InitInternalResult::Complete(pool) => Ok(V3InitPhase1Result::Complete(pool)), - V3InitInternalResult::DeferredResync(pool) => { - Ok(V3InitPhase1Result::NeedsResync { pool, flavor }) - } - V3InitInternalResult::DeferredIncrementalResync(pool, old_bitmap, old_ticks) => { - Ok(V3InitPhase1Result::NeedsIncrementalResync { - pool, - flavor, - old_bitmap, - old_ticks, - }) - } - } -} - -/// Internal result from init_v3_from_cache -- never leaves resync inline. -/// The caller decides whether to complete the resync or defer it. -enum V3InitInternalResult { - Complete(UniswapV3Pool), - DeferredResync(UniswapV3Pool), - DeferredIncrementalResync(UniswapV3Pool, HashMap, HashMap), -} - -/// Internal status for tick cache handling in init_uniswap_v3_from_cache -#[derive(Debug)] -enum TickCacheStatus { - /// Storage is pre-loaded from evm_state.json, no injection needed - PreloadedStorage, - /// Tick snapshot is valid but needs to be injected into EVM storage - SnapshotNeedsInjection, - /// Need full resync from chain (no snapshot available) - NeedsResync, - /// Has stale snapshot -- do incremental resync (compare bitmaps, re-fetch changed ticks) - NeedsIncrementalResync { - old_bitmap: HashMap, - old_ticks: HashMap, - }, -} - -fn cache_tick_to_info(tick: CacheTickInfo) -> Info { - Info { - liquidity_gross: tick.liquidity_gross, - liquidity_net: tick.liquidity_net, - initialized: tick.initialized, - } -} - -fn info_to_cache_tick(info: &Info) -> CacheTickInfo { - CacheTickInfo { - liquidity_gross: info.liquidity_gross, - liquidity_net: info.liquidity_net, - initialized: info.initialized, - } -} - -fn cache_ticks_to_info(ticks: HashMap) -> HashMap { - ticks - .into_iter() - .map(|(tick, info)| (tick, cache_tick_to_info(info))) - .collect() -} - -fn info_ticks_to_cache(ticks: &HashMap) -> HashMap { - ticks - .iter() - .map(|(tick, info)| (*tick, info_to_cache_tick(info))) - .collect() -} - -fn can_reuse_v3_tick_snapshot( - cached_liquidity: u128, - current_liquidity: u128, - cached_tick: i32, - current_tick: i32, -) -> bool { - cached_liquidity == current_liquidity - && (cached_tick - current_tick).abs() <= MAX_TICK_DRIFT_FOR_CACHE -} - -fn collect_targeted_tick_words( - current_tick: i32, - tick_spacing: i32, - affected_tick_ranges: &[(i32, i32)], -) -> HashSet { - let mut affected_words = HashSet::new(); - if tick_spacing == 0 { - return affected_words; - } - - for &(tick_lower, tick_upper) in affected_tick_ranges { - let lower_word = tick_to_word(tick_lower, tick_spacing); - let upper_word = tick_to_word(tick_upper, tick_spacing); - for word in lower_word..=upper_word { - affected_words.insert(word as i16); - } - } - - affected_words.insert(tick_to_word(current_tick, tick_spacing) as i16); - affected_words -} - -async fn init_v3_from_cache( - cache: &mut EvmCache, - address: Address, - flavor: V3Flavor, -) -> Result { - let fn_start = Instant::now(); - - let t0 = Instant::now(); - cache.ensure_account(address).await?; - let ensure_ms = t0.elapsed().as_millis(); - - // Check if this pool has pre-loaded storage from the unified EVM state cache - let has_preloaded_storage = cache.has_pool_storage(address); - let preloaded_slot_count = if has_preloaded_storage { - cache.pool_storage_slot_count(address) - } else { - 0 - }; - - // Try to load immutable metadata from cache (token0, token1, fee, tickSpacing) - let t1 = Instant::now(); - let (token0, token1, fee, tick_spacing) = - if let Some(metadata) = cache.immutable_cache().get_v3_pool(address) { - debug!("using cached V3 pool metadata"); - ( - metadata.token0, - metadata.token1, - metadata.fee, - metadata.tick_spacing, - ) - } else { - // Fetch immutable data from RPC and cache - let token0 = call_view(cache, address, IUniswapV3Pool::token0Call {})?; - let token1 = call_view(cache, address, IUniswapV3Pool::token1Call {})?; - let fee_raw = call_view(cache, address, IUniswapV3Pool::feeCall {})?; - let tick_spacing_raw = call_view(cache, address, IUniswapV3Pool::tickSpacingCall {})?; - - let fee = fee_raw.to::(); - let tick_spacing = tick_spacing_raw.as_i32(); - - cache.immutable_cache_mut().set_v3_pool( - address, - V3PoolMetadata { - token0, - token1, - fee, - tick_spacing, - }, - ); - debug!("fetched and cached V3 pool metadata"); - (token0, token1, fee, tick_spacing) - }; - let metadata_ms = t1.elapsed().as_millis(); - - // Selectively purge only slot0 and liquidity slots to force fresh RPC reads. - // This preserves tick bitmap and tick info data in the cache, avoiding expensive - // re-injection or re-fetch of tick data when the snapshot is still valid. - cache.purge_pool_slots(address, &[V3_SLOT0_SLOT, flavor.liquidity_slot()]); - - // Mutable data: slot0 and liquidity must be fetched fresh - let t2 = Instant::now(); - let slot0 = call_view(cache, address, IUniswapV3Pool::slot0Call {})?; - let liquidity = call_view(cache, address, IUniswapV3Pool::liquidityCall {})?; - let slot0_ms = t2.elapsed().as_millis(); - - let dec0 = cache.erc20_decimals(token0).unwrap_or(18); - let dec1 = cache.erc20_decimals(token1).unwrap_or(18); - - let current_tick = slot0.tick.as_i32(); - - // Try to restore tick data from snapshot cache - // Four possible outcomes: - // 1. Liquidity unchanged + tick drift OK + storage pre-loaded: use cached data - // 2. Liquidity unchanged + tick drift OK + no storage: use cached, inject into EVM - // 3. Validation fails: purge old storage (if any), resync from chain - // 4. No snapshot: purge and resync - - let t3 = Instant::now(); - - // First, extract snapshot data to avoid borrow conflicts later. - let snapshot_data = cache.tick_snapshot_cache().get(address).map(|snapshot| { - ( - snapshot.last_liquidity, - snapshot.last_tick, - snapshot.to_tick_bitmap(), - cache_ticks_to_info(snapshot.to_ticks()), - snapshot.ticks.len(), - ) - }); - - let (tick_bitmap, ticks, cache_status) = if let Some(( - cached_liquidity, - cached_tick, - cached_bitmap, - cached_ticks, - cached_ticks_count, - )) = snapshot_data - { - let tick_drift = (cached_tick - current_tick).abs(); - if cached_bitmap.is_empty() { - debug!(pool = %address, "tick snapshot empty, will do incremental resync"); - ( - HashMap::new(), - HashMap::new(), - TickCacheStatus::NeedsIncrementalResync { - old_bitmap: cached_bitmap, - old_ticks: cached_ticks, - }, - ) - } else if can_reuse_v3_tick_snapshot(cached_liquidity, liquidity, cached_tick, current_tick) - { - let status = if has_preloaded_storage { - debug!( - last_liquidity = cached_liquidity, - current_liquidity = liquidity, - tick_drift, - preloaded_slots = preloaded_slot_count, - cached_ticks = cached_ticks_count, - cached_bitmap_words = cached_bitmap.len(), - "using pre-loaded EVM storage (validation passed: liquidity + tick drift)" - ); - TickCacheStatus::PreloadedStorage - } else { - debug!( - last_liquidity = cached_liquidity, - current_liquidity = liquidity, - tick_drift, - cached_ticks = cached_ticks_count, - cached_bitmap_words = cached_bitmap.len(), - "using cached V3 tick snapshot (validation passed, will inject)" - ); - TickCacheStatus::SnapshotNeedsInjection - }; - (cached_bitmap, cached_ticks, status) - } else { - if cached_liquidity != liquidity { - debug!( - cached_liquidity, - current_liquidity = liquidity, - has_preloaded_storage, - "tick snapshot: liquidity changed, will do incremental resync" - ); - } else { - debug!( - cached_tick, - current_tick, - tick_drift, - max_drift = MAX_TICK_DRIFT_FOR_CACHE, - "tick snapshot: tick drift exceeded, will do incremental resync" - ); - } - ( - HashMap::new(), - HashMap::new(), - TickCacheStatus::NeedsIncrementalResync { - old_bitmap: cached_bitmap, - old_ticks: cached_ticks, - }, - ) - } - } else { - debug!(has_preloaded_storage, "no tick snapshot in cache"); - // No snapshot - if we have storage, it might be stale, but we can't validate - // Better to purge and resync to be safe - if has_preloaded_storage { - let purged = cache.purge_pool_storage(address); - debug!( - pool = %address, - purged_slots = purged, - "purged pool storage (no tick snapshot for validation)" - ); - } - (HashMap::new(), HashMap::new(), TickCacheStatus::NeedsResync) - }; - - let mut pool = UniswapV3Pool { - address, - token_a: Token::new_with_decimals(token0, dec0), - token_b: Token::new_with_decimals(token1, dec1), - fee, - tick_spacing, - sqrt_price: slot0.sqrtPriceX96.to(), - tick: current_tick, - liquidity, - tick_bitmap, - ticks, - }; - - let validation_ms = t3.elapsed().as_millis(); - - let t4 = Instant::now(); - let cache_status_label = match &cache_status { - TickCacheStatus::PreloadedStorage => "PreloadedStorage", - TickCacheStatus::SnapshotNeedsInjection => "SnapshotNeedsInjection", - TickCacheStatus::NeedsResync => "NeedsResync", - TickCacheStatus::NeedsIncrementalResync { .. } => "NeedsIncrementalResync", - }; - match cache_status { - TickCacheStatus::PreloadedStorage => { - debug!( - pool = %address, - storage_slots = preloaded_slot_count, - "V3 pool initialized from pre-loaded EVM storage (fast path)" - ); - } - TickCacheStatus::SnapshotNeedsInjection => { - // Inject tick data into EVM storage cache so subsequent EVM calls - // to tickBitmap() and ticks() hit the cache instead of going to RPC - let mut bitmap_injected = 0; - let mut ticks_injected = 0; - - match cache.inject_v3_tick_bitmap_with_base( - address, - &pool.tick_bitmap, - flavor.tick_bitmap_base_slot(), - ) { - Ok(count) => bitmap_injected = count, - Err(e) => { - warn!( - pool = %address, - error = %e, - "failed to inject tick bitmap into storage cache" - ); - } - } - - let cache_ticks = info_ticks_to_cache(&pool.ticks); - match cache.inject_v3_ticks_with_base(address, &cache_ticks, flavor.ticks_base_slot()) { - Ok(count) => ticks_injected = count, - Err(e) => { - warn!( - pool = %address, - error = %e, - "failed to inject ticks into storage cache" - ); - } - } - - debug!( - pool = %address, - bitmap_words = bitmap_injected, - ticks = ticks_injected, - "injected V3 tick data into EVM storage cache" - ); - } - TickCacheStatus::NeedsResync => { - // No snapshot -- defer resync to caller so it can batch-prefetch - // bitmap storage slots in parallel across all pools. - let tick_resolution_ms = t4.elapsed().as_millis(); - let total_ms = fn_start.elapsed().as_millis(); - debug!( - pool = %address, - ensure_ms, - metadata_ms, - slot0_ms, - validation_ms, - tick_resolution_ms, - total_ms, - cache_status = "NeedsResync (deferred)", - has_preloaded_storage, - preloaded_slot_count, - "V3 pool init phase1 — deferring resync" - ); - return Ok(V3InitInternalResult::DeferredResync(pool)); - } - TickCacheStatus::NeedsIncrementalResync { - old_bitmap, - old_ticks, - } => { - // Defer incremental resync to caller for parallel bitmap prefetch. - // This avoids hundreds of sequential RPC calls per pool. - let tick_resolution_ms = t4.elapsed().as_millis(); - let total_ms = fn_start.elapsed().as_millis(); - debug!( - pool = %address, - ensure_ms, - metadata_ms, - slot0_ms, - validation_ms, - tick_resolution_ms, - total_ms, - cache_status = "NeedsIncrementalResync (deferred)", - has_preloaded_storage, - preloaded_slot_count, - "V3 pool init phase1 — deferring incremental resync" - ); - return Ok(V3InitInternalResult::DeferredIncrementalResync( - pool, old_bitmap, old_ticks, - )); - } - } - let mut lazy_extended = false; - if !pool.tick_bitmap.is_empty() && needs_tick_resync(&pool, pool.tick) { - extend_v3_tick_region(cache, &mut pool, flavor)?; - lazy_extended = true; - } - let tick_resolution_ms = t4.elapsed().as_millis(); - let total_ms = fn_start.elapsed().as_millis(); - - debug!( - pool = %address, - ensure_ms, - metadata_ms, - slot0_ms, - validation_ms, - tick_resolution_ms, - total_ms, - cache_status = cache_status_label, - has_preloaded_storage, - preloaded_slot_count, - lazy_extended, - tick_bitmap_words = pool.tick_bitmap.len(), - ticks_count = pool.ticks.len(), - "V3 pool init breakdown" - ); - - Ok(V3InitInternalResult::Complete(pool)) -} - -/// Save a tick snapshot for a V3 pool to the cache. -/// -/// Call this after syncing tick data to persist for future restarts. -pub fn save_v3_tick_snapshot(cache: &mut EvmCache, pool: &UniswapV3Pool) { - let cache_ticks = info_ticks_to_cache(&pool.ticks); - let snapshot = V3PoolTickSnapshot::from_pool_data( - &pool.tick_bitmap, - &cache_ticks, - pool.liquidity, - pool.tick, - ); - cache.tick_snapshot_cache_mut().set(pool.address, snapshot); - debug!( - pool = %pool.address, - ticks = pool.ticks.len(), - bitmap_words = pool.tick_bitmap.len(), - liquidity = pool.liquidity, - "saved V3 tick snapshot to cache" - ); -} - -/// Extend the pool's loaded tick region to cover the current tick. -/// -/// When the tick moves to a bitmap word not present in `pool.tick_bitmap`, -/// [`needs_tick_resync`] fires. Instead of clearing all tick data and doing -/// a full adaptive scan (which is expensive), this function loads only the -/// missing bitmap word(s) in a zone around the new tick position and fetches -/// tick info for any newly discovered initialized ticks. Existing tick data -/// outside the new region is preserved. -/// -/// This is safe because initialized ticks don't change their liquidityNet -/// unless a Mint/Burn event occurs. Mint/Burn events now queue -/// `pending_tick_changes` in hot state, which are handled via targeted resync -/// before `sync_selected_pool_state` falls back to a broader RPC refresh. -#[instrument(skip(cache), fields(pool = %pool.address, tick_spacing = pool.tick_spacing, current_tick = pool.tick))] -pub fn extend_v3_tick_region( - cache: &mut EvmCache, - pool: &mut UniswapV3Pool, - flavor: V3Flavor, -) -> Result<()> { - if pool.tick_spacing == 0 { - return Ok(()); - } - - let fn_start = Instant::now(); - let current_word = tick_to_word(pool.tick, pool.tick_spacing); - let min_word = tick_to_word(MIN_TICK, pool.tick_spacing); - let max_word = tick_to_word(MAX_TICK, pool.tick_spacing); - let bitmap_base = flavor.tick_bitmap_base_slot(); - - // Load a zone of cycle_hot_zone_radius() words around the current tick. - // Only fetch words not already present in the pool's bitmap. - let hot_radius = cycle_hot_zone_radius(); - let zone_start = (current_word - hot_radius).max(min_word); - let zone_end = (current_word + hot_radius).min(max_word); - - let mut new_words = 0usize; - let mut new_ticks = Vec::new(); - - for word in zone_start..=zone_end { - let word_i16 = word as i16; - if pool.tick_bitmap.contains_key(&word_i16) { - continue; // Already loaded - } - - // Purge and read the bitmap word fresh from RPC - let slot = v3_tick_bitmap_storage_key_with_base(word_i16, bitmap_base); - cache.purge_pool_slots(pool.address, &[slot]); - let bitmap = cache.read_storage_slot(pool.address, slot)?; - - pool.tick_bitmap.insert(word_i16, bitmap); - new_words += 1; - - // Extract initialized ticks from this new word - if bitmap != U256::ZERO { - let ticks = extract_ticks_from_bitmap_word(word, bitmap, pool.tick_spacing); - new_ticks.extend(ticks); - } - } - - // Fetch tick info for all newly discovered initialized ticks - if !new_ticks.is_empty() { - let tick_infos = - fetch_tick_info_raw_storage(cache, pool.address, &new_ticks, flavor, false)?; - for (tick, info) in tick_infos { - pool.ticks.insert(tick, info); - } - } - - let elapsed_ms = fn_start.elapsed().as_millis(); - debug!( - pool = %pool.address, - new_words, - new_ticks = new_ticks.len(), - total_bitmap_words = pool.tick_bitmap.len(), - total_ticks = pool.ticks.len(), - elapsed_ms, - "extended V3 tick region (lazy)" - ); - - // Persist updated tick data - save_v3_tick_snapshot(cache, pool); - - // Inject new tick data into EVM storage cache so subsequent SLOADs hit cache - inject_v3_tick_data(cache, pool.address, pool, flavor); - - Ok(()) -} - -/// Inject V3 tick bitmap and tick info into EVM storage cache after incremental resync. -/// -/// After incremental_sync_v3_ticks, bitmaps are fresh (from raw storage reads) but the -/// CacheDB overlay was cleared during purge. Restored ticks (from unchanged words) are -/// not in BlockchainDb. This function re-injects both to ensure EVM reads hit cache. -pub fn inject_v3_tick_data( - cache: &mut EvmCache, - address: Address, - pool: &UniswapV3Pool, - flavor: V3Flavor, -) { - if let Err(e) = cache.inject_v3_tick_bitmap_with_base( - address, - &pool.tick_bitmap, - flavor.tick_bitmap_base_slot(), - ) { - warn!(pool = %address, error = %e, "failed to inject tick bitmap after incremental resync"); - } - let cache_ticks = info_ticks_to_cache(&pool.ticks); - if let Err(e) = cache.inject_v3_ticks_with_base(address, &cache_ticks, flavor.ticks_base_slot()) - { - warn!(pool = %address, error = %e, "failed to inject ticks after incremental resync"); - } -} - -/// Incremental resync of V3 tick data using bitmap comparison. -/// -/// Instead of fetching all ticks from scratch, this function: -/// 1. Scans bitmap words in a bounded range around the current tick -/// 2. Compares fresh bitmap values against cached values -/// 3. In the "hot zone" near current tick: always re-fetches tick info -/// (catches liquidity changes without bitmap changes) -/// 4. Outside hot zone: only re-fetches ticks in changed bitmap words -/// 5. Restores cached tick info for unchanged words outside hot zone -/// -/// The pool's tick_bitmap and ticks are rebuilt from scratch within the scan range. -#[instrument(skip(cache, old_bitmap, old_ticks), fields(pool = %pool.address, tick_spacing = pool.tick_spacing, current_tick = pool.tick))] -pub fn incremental_sync_v3_ticks( - cache: &mut EvmCache, - pool: &mut UniswapV3Pool, - old_bitmap: &HashMap, - old_ticks: &HashMap, - flavor: V3Flavor, - pre_purged: bool, -) -> Result<()> { - if pool.tick_spacing == 0 { - return Ok(()); - } - - let fn_start = Instant::now(); - - let min_word = tick_to_word(MIN_TICK, pool.tick_spacing); - let max_word = tick_to_word(MAX_TICK, pool.tick_spacing); - let current_word = tick_to_word(pool.tick, pool.tick_spacing); - - // Clear pool data -- we'll rebuild from scan range - pool.tick_bitmap.clear(); - pool.ticks.clear(); - - let scan_params = compute_adaptive_scan_params(pool.liquidity, pool.tick_spacing); - debug!( - pool = %pool.address, - liquidity = pool.liquidity, - tick_spacing = pool.tick_spacing, - max_scan_words = scan_params.max_scan_words, - empty_word_threshold = scan_params.empty_word_threshold, - "incremental adaptive scan params" - ); - - // Step 1: Determine scan range using outward scan, using OLD bitmap for boundaries. - // This ensures we scan at least as far as the old data covered near current tick, - // and extends further if needed. - let mut words_to_scan = Vec::new(); - - // Positive direction - let mut consecutive_empty_up = 0; - for offset in 0..=scan_params.max_scan_words { - let word = current_word + offset; - if word > max_word { - break; - } - words_to_scan.push(word as i16); - let cached_val = old_bitmap - .get(&(word as i16)) - .copied() - .unwrap_or(U256::ZERO); - if cached_val == U256::ZERO { - consecutive_empty_up += 1; - if consecutive_empty_up >= scan_params.empty_word_threshold { - break; - } - } else { - consecutive_empty_up = 0; - } - } - - // Negative direction - let mut consecutive_empty_down = 0; - for offset in 1..=scan_params.max_scan_words { - let word = current_word - offset; - if word < min_word { - break; - } - words_to_scan.push(word as i16); - let cached_val = old_bitmap - .get(&(word as i16)) - .copied() - .unwrap_or(U256::ZERO); - if cached_val == U256::ZERO { - consecutive_empty_down += 1; - if consecutive_empty_down >= scan_params.empty_word_threshold { - break; - } - } else { - consecutive_empty_down = 0; - } - } - - let scan_start = Instant::now(); - let bitmap_base = flavor.tick_bitmap_base_slot(); - - // Step 2: Purge bitmap storage slots for entire scan range to force fresh RPC reads - // (skip if already purged and prefetched by the parallel prefetch phase) - if !pre_purged { - let bitmap_slots_to_purge: Vec = words_to_scan - .iter() - .map(|&w| v3_tick_bitmap_storage_key_with_base(w, bitmap_base)) - .collect(); - cache.purge_pool_slots(pool.address, &bitmap_slots_to_purge); - } - - // Step 3: Fetch fresh bitmaps and compare with cached - let mut words_scanned = 0; - let mut words_changed = 0; - let mut words_hot_zone = 0; - let mut ticks_to_fetch = Vec::new(); - - for &word in &words_to_scan { - words_scanned += 1; - - let slot = v3_tick_bitmap_storage_key_with_base(word, bitmap_base); - let fresh_bitmap = cache.read_storage_slot(pool.address, slot)?; - - let cached_value = old_bitmap.get(&word).copied().unwrap_or(U256::ZERO); - pool.tick_bitmap.insert(word, fresh_bitmap); - - let word_i32 = word as i32; - let in_hot_zone = - (word_i32 - current_word).unsigned_abs() < incremental_hot_zone_radius() as u32; - - if in_hot_zone { - // Hot zone: always re-fetch all initialized ticks (catches liquidity changes) - words_hot_zone += 1; - let fresh_ticks = - extract_ticks_from_bitmap_word(word_i32, fresh_bitmap, pool.tick_spacing); - ticks_to_fetch.extend_from_slice(&fresh_ticks); - - if fresh_bitmap != cached_value { - words_changed += 1; - } - } else if fresh_bitmap != cached_value { - // Outside hot zone with changed bitmap: re-fetch ticks for this word - words_changed += 1; - let fresh_ticks = - extract_ticks_from_bitmap_word(word_i32, fresh_bitmap, pool.tick_spacing); - ticks_to_fetch.extend_from_slice(&fresh_ticks); - } else { - // Outside hot zone, bitmap unchanged: restore cached tick info - let cached_tick_indices = - extract_ticks_from_bitmap_word(word_i32, cached_value, pool.tick_spacing); - for tick_idx in cached_tick_indices { - if let Some(info) = old_ticks.get(&tick_idx) { - pool.ticks.insert(tick_idx, info.clone()); - } - } - } - } - - let scan_ms = scan_start.elapsed().as_millis(); - - // Step 4: Fetch fresh tick info via raw storage reads (slots 0+3 only) - // Raw reads populate BlockchainDb directly, so EVM SLOADs find fresh values - let batch_start = Instant::now(); - if !ticks_to_fetch.is_empty() { - let fresh_ticks = - fetch_tick_info_raw_storage(cache, pool.address, &ticks_to_fetch, flavor, pre_purged)?; - for (tick, info) in fresh_ticks { - pool.ticks.insert(tick, info); - } - } - let batch_ms = batch_start.elapsed().as_millis(); - - let ticks_restored = pool.ticks.len().saturating_sub(ticks_to_fetch.len()); - let total_ms = fn_start.elapsed().as_millis(); - - debug!( - pool = %pool.address, - words_scanned, - words_changed, - words_hot_zone, - ticks_refreshed = ticks_to_fetch.len(), - ticks_restored, - ticks_total = pool.ticks.len(), - scan_ms, - batch_fetch_ms = batch_ms, - total_ms, - "incremental_sync_v3_ticks breakdown" - ); - - // Save updated snapshot - save_v3_tick_snapshot(cache, pool); - - Ok(()) -} - -/// Sync UniswapV3 tick data using smart range scanning. -/// -/// Instead of scanning the entire tick range (which can be 6000+ RPC calls), -/// this function scans outward from the current tick position and stops when -/// it hits consecutive empty bitmap words. This typically reduces RPC calls -/// by 95%+ for most pools. -#[instrument(skip(cache), fields(pool = %pool.address, tick_spacing = pool.tick_spacing, current_tick = pool.tick))] -pub fn sync_uniswap_v3_ticks( - cache: &mut EvmCache, - pool: &mut UniswapV3Pool, - flavor: V3Flavor, -) -> Result<()> { - if pool.tick_spacing == 0 { - return Ok(()); - } - - let fn_start = Instant::now(); - - let min_word = tick_to_word(MIN_TICK, pool.tick_spacing); - let max_word = tick_to_word(MAX_TICK, pool.tick_spacing); - let current_word = tick_to_word(pool.tick, pool.tick_spacing); - - pool.tick_bitmap.clear(); - pool.ticks.clear(); - - let scan_params = compute_adaptive_scan_params(pool.liquidity, pool.tick_spacing); - debug!( - pool = %pool.address, - liquidity = pool.liquidity, - tick_spacing = pool.tick_spacing, - max_scan_words = scan_params.max_scan_words, - empty_word_threshold = scan_params.empty_word_threshold, - "adaptive scan params" - ); - - // Scan outward from current tick position using direct storage reads - let mut words_scanned = 0; - let mut words_with_liquidity = 0; - let bitmap_base = flavor.tick_bitmap_base_slot(); - - let scan_start = Instant::now(); - - // Scan in positive direction (higher ticks) - let mut consecutive_empty_up = 0; - for offset in 0..=scan_params.max_scan_words { - let word = current_word + offset; - if word > max_word { - break; - } - - let slot = v3_tick_bitmap_storage_key_with_base(word as i16, bitmap_base); - let bitmap = cache.read_storage_slot(pool.address, slot)?; - - words_scanned += 1; - pool.tick_bitmap.insert(word as i16, bitmap); - - if bitmap == U256::ZERO { - consecutive_empty_up += 1; - if consecutive_empty_up >= scan_params.empty_word_threshold { - break; - } - } else { - consecutive_empty_up = 0; - words_with_liquidity += 1; - } - } - - // Scan in negative direction (lower ticks), starting from current_word - 1 - let mut consecutive_empty_down = 0; - for offset in 1..=scan_params.max_scan_words { - let word = current_word - offset; - if word < min_word { - break; - } - - let slot = v3_tick_bitmap_storage_key_with_base(word as i16, bitmap_base); - let bitmap = cache.read_storage_slot(pool.address, slot)?; - - words_scanned += 1; - pool.tick_bitmap.insert(word as i16, bitmap); - - if bitmap == U256::ZERO { - consecutive_empty_down += 1; - if consecutive_empty_down >= scan_params.empty_word_threshold { - break; - } - } else { - consecutive_empty_down = 0; - words_with_liquidity += 1; - } - } - - let scan_ms = scan_start.elapsed().as_millis(); - - // Extract initialized ticks from the scanned bitmaps - let initialized_ticks = extract_initialized_ticks_from_bitmap(pool); - - // Fetch tick info using raw storage reads (slots 0+3 only). - // Only liquidityGross, liquidityNet (slot 0) and initialized (slot 3) are needed. - // If bitmap/tick info slots were prefetched, these reads hit BlockchainDb cache. - let batch_start = Instant::now(); - let ticks_base = flavor.ticks_base_slot(); - for &tick in &initialized_ticks { - let keys = v3_tick_info_storage_keys_with_base(tick, ticks_base); - let slot0_value = cache.read_storage_slot(pool.address, keys[0])?; - let limbs = slot0_value.as_limbs(); - let liquidity_gross: u128 = limbs[0] as u128 | ((limbs[1] as u128) << 64); - let liquidity_net: i128 = (limbs[2] as u128 | ((limbs[3] as u128) << 64)) as i128; - - let slot3_value = cache.read_storage_slot(pool.address, keys[3])?; - let initialized = (slot3_value >> 248) & U256::from(1u64) != U256::ZERO; - - pool.ticks.insert( - tick, - Info { - liquidity_gross, - liquidity_net, - initialized, - }, - ); - } - let batch_ms = batch_start.elapsed().as_millis(); - - let total_ms = fn_start.elapsed().as_millis(); - - debug!( - pool = %pool.address, - words_scanned, - words_with_liquidity, - bitmap_words = pool.tick_bitmap.len(), - ticks_loaded = pool.ticks.len(), - initialized_ticks_found = initialized_ticks.len(), - scan_ms, - batch_fetch_ms = batch_ms, - total_ms, - "sync_uniswap_v3_ticks breakdown" - ); - - // Save tick snapshot for future restarts - save_v3_tick_snapshot(cache, pool); - - Ok(()) -} - -/// Full tick range sync - scans the entire tick range. -/// -/// Use this as a fallback when smart scanning might miss liquidity, -/// or for initial verification that the smart scan is working correctly. -#[instrument(skip(cache), fields(pool = %pool.address, tick_spacing = pool.tick_spacing))] -pub fn sync_uniswap_v3_ticks_full(cache: &mut EvmCache, pool: &mut UniswapV3Pool) -> Result<()> { - if pool.tick_spacing == 0 { - return Ok(()); - } - - let min_word = tick_to_word(MIN_TICK, pool.tick_spacing); - let max_word = tick_to_word(MAX_TICK, pool.tick_spacing); - - pool.tick_bitmap.clear(); - pool.ticks.clear(); - - warn!( - pool = %pool.address, - word_range = max_word - min_word + 1, - "performing FULL tick bitmap scan - this will be slow" - ); - - for word in min_word..=max_word { - let bitmap = call_view( - cache, - pool.address, - IUniswapV3Pool::tickBitmapCall { - wordPosition: word as i16, - }, - )?; - pool.tick_bitmap.insert(word as i16, bitmap); - } - - let initialized_ticks = extract_initialized_ticks_from_bitmap(pool); - - // Batch fetch tick info using Multicall3 - if !initialized_ticks.is_empty() { - fetch_tick_info_batched(cache, pool, &initialized_ticks)?; - } - - debug!( - bitmap_words = pool.tick_bitmap.len(), - ticks_loaded = pool.ticks.len(), - "synced V3 ticks (full scan + batched tick info)" - ); - - // Save tick snapshot for future restarts - save_v3_tick_snapshot(cache, pool); - - Ok(()) -} - -/// Batch fetch tick info for multiple ticks using Multicall3. -/// -/// This significantly reduces RPC calls when a pool has many initialized ticks. -/// Instead of N individual calls, this batches them into ceil(N/MAX_BATCH_SIZE) calls. -fn fetch_tick_info_batched( - cache: &mut EvmCache, - pool: &mut UniswapV3Pool, - initialized_ticks: &[i32], -) -> Result<()> { - use alloy_primitives::Bytes; - use evm_fork_cache::multicall::execute_batched; - - if initialized_ticks.is_empty() { - return Ok(()); - } - - // Build calls for batched execution - let calls: Vec<(Address, Bytes, bool)> = initialized_ticks - .iter() - .filter_map(|&tick| { - let tick_value = alloy_primitives::Signed::<24, 1>::try_from(tick as i128).ok()?; - let call_data = IUniswapV3Pool::ticksCall { tick: tick_value }.abi_encode(); - Some((pool.address, Bytes::from(call_data), true)) - }) - .collect(); - - // Execute with proper chunking (respects MAX_BATCH_SIZE) - let results = execute_batched(cache, calls)?; - - // Process results - let mut successful = 0; - for (&tick, result) in initialized_ticks.iter().zip(results.iter()) { - if result.success - && let Ok(tick_info) = IUniswapV3Pool::ticksCall::abi_decode_returns(&result.returnData) - { - pool.ticks.insert( - tick, - Info { - liquidity_gross: tick_info.liquidityGross, - liquidity_net: tick_info.liquidityNet, - initialized: tick_info.initialized, - }, - ); - successful += 1; - } - } - - debug!( - requested = initialized_ticks.len(), - successful, "fetched tick info via multicall" - ); - - Ok(()) -} - -/// Fetch tick info using raw storage reads (slots 0 and 3 only). -/// -/// Instead of calling the `ticks()` getter (which reads all 4 Tick.Info slots), -/// this reads only slot 0 (liquidityGross + liquidityNet) and slot 3 (initialized flag) -/// directly via raw storage access. This cuts from 4 to 2 RPC calls per tick. -/// -/// The raw reads go through `cache.read_storage_slot()` which populates BlockchainDb, -/// so subsequent EVM SLOADs will find the values cached there. -pub(super) fn fetch_tick_info_raw_storage( - cache: &mut EvmCache, - pool_address: Address, - ticks_to_fetch: &[i32], - flavor: V3Flavor, - skip_purge: bool, -) -> Result> { - let mut result = HashMap::with_capacity(ticks_to_fetch.len()); - - if ticks_to_fetch.is_empty() { - return Ok(result); - } - - let ticks_base = flavor.ticks_base_slot(); - - // Purge slots 0 and 3 for all ticks, then read fresh values - // Skip purge when slots were already freshly fetched by parallel prefetch - if !skip_purge { - let slots_to_purge: Vec = ticks_to_fetch - .iter() - .flat_map(|&tick| { - let keys = v3_tick_info_storage_keys_with_base(tick, ticks_base); - [keys[0], keys[3]] - }) - .collect(); - cache.purge_pool_slots(pool_address, &slots_to_purge); - } - - let mut successful = 0; - for &tick in ticks_to_fetch { - let keys = v3_tick_info_storage_keys_with_base(tick, ticks_base); - - // Read slot 0: liquidityGross (lower 128 bits) | liquidityNet (upper 128 bits) - // U256 limbs are in little-endian order: [bits 0-63, 64-127, 128-191, 192-255] - let slot0_value = cache.read_storage_slot(pool_address, keys[0])?; - let limbs = slot0_value.as_limbs(); - let liquidity_gross: u128 = limbs[0] as u128 | ((limbs[1] as u128) << 64); - let liquidity_net: i128 = (limbs[2] as u128 | ((limbs[3] as u128) << 64)) as i128; - - // Read slot 3: initialized flag at bit 248 - let slot3_value = cache.read_storage_slot(pool_address, keys[3])?; - let initialized = (slot3_value >> 248) & U256::from(1u64) != U256::ZERO; - - result.insert( - tick, - Info { - liquidity_gross, - liquidity_net, - initialized, - }, - ); - successful += 1; - } - - debug!( - pool = %pool_address, - requested = ticks_to_fetch.len(), - successful, - "fetched tick info via raw storage (slots 0+3)" - ); - - Ok(result) -} - -/// Refresh only the slot0 data (sqrtPrice, tick, liquidity) for a V3 pool. -/// -/// This is useful for incremental updates where we don't need to re-scan -/// all tick data, just update the current pool state. -#[instrument(skip(cache), fields(pool = %pool.address))] -pub fn refresh_uniswap_v3_slot0(cache: &mut EvmCache, pool: &mut UniswapV3Pool) -> Result<()> { - refresh_v3_slot0(cache, pool, V3Flavor::UniswapV3) -} - -pub(super) fn refresh_v3_slot0( - cache: &mut EvmCache, - pool: &mut UniswapV3Pool, - flavor: V3Flavor, -) -> Result<()> { - // Selectively purge only slot0 and liquidity to force fresh reads from RPC. - // This preserves tick bitmap and tick info data in the cache, which is critical - // since this function is called every cycle for every V3 pool. - cache.purge_pool_slots(pool.address, &[V3_SLOT0_SLOT, flavor.liquidity_slot()]); - - let slot0 = call_view(cache, pool.address, IUniswapV3Pool::slot0Call {})?; - let liquidity = call_view(cache, pool.address, IUniswapV3Pool::liquidityCall {})?; - - let old_tick = pool.tick; - pool.sqrt_price = slot0.sqrtPriceX96.to(); - pool.tick = slot0.tick.as_i32(); - pool.liquidity = liquidity; - - debug!( - old_tick, - new_tick = pool.tick, - liquidity = %pool.liquidity, - "refreshed V3 slot0" - ); - - Ok(()) -} - -/// Refresh the V3 pool state: slot0 + liquidity + hot-zone tick data. -/// -/// This is a per-cycle refresh that goes beyond `refresh_uniswap_v3_slot0` by also -/// refreshing tick bitmap and tick info in the hot zone around the current tick. -/// This catches LP add/remove operations that change tick liquidity without -/// necessarily changing the pool's global liquidity or current tick. -/// -/// The hot zone covers `cycle_hot_zone_radius()` words in each direction from -/// the current tick's word, ensuring that swap simulations crossing nearby -/// ticks use fresh liquidityGross/liquidityNet values. -#[instrument(skip(cache), fields(pool = %pool.address))] -pub fn refresh_uniswap_v3_state(cache: &mut EvmCache, pool: &mut UniswapV3Pool) -> Result<()> { - refresh_v3_state(cache, pool, V3Flavor::UniswapV3) -} - -pub fn refresh_pancakeswap_v3_state(cache: &mut EvmCache, pool: &mut UniswapV3Pool) -> Result<()> { - refresh_v3_state(cache, pool, V3Flavor::PancakeSwapV3) -} - -pub(super) fn refresh_v3_state( - cache: &mut EvmCache, - pool: &mut UniswapV3Pool, - flavor: V3Flavor, -) -> Result<()> { - let fn_start = Instant::now(); - - // Step 1: Refresh slot0 + liquidity (existing behavior) - refresh_v3_slot0(cache, pool, flavor)?; - - if pool.tick_spacing == 0 || pool.tick_bitmap.is_empty() { - return Ok(()); - } - - // Step 2: Refresh tick bitmap + tick info in the hot zone - let current_word = tick_to_word(pool.tick, pool.tick_spacing); - - let hot_radius = cycle_hot_zone_radius(); - let hot_zone_words: Vec = ((-hot_radius)..=hot_radius) - .map(|offset| (current_word + offset) as i16) - .collect(); - - // Purge bitmap storage slots for the hot zone - let bitmap_slots_to_purge: Vec = hot_zone_words - .iter() - .map(|&w| v3_tick_bitmap_storage_key_with_base(w, flavor.tick_bitmap_base_slot())) - .collect(); - cache.purge_pool_slots(pool.address, &bitmap_slots_to_purge); - - // Fetch fresh bitmap values and compare with existing. - // Only re-fetch tick info for words whose bitmap actually changed. - let mut words_changed = 0; - let mut ticks_to_fetch = Vec::new(); - - for &word in &hot_zone_words { - let fresh_bitmap = call_view( - cache, - pool.address, - IUniswapV3Pool::tickBitmapCall { wordPosition: word }, - )?; - - let old_bitmap = pool.tick_bitmap.get(&word).copied().unwrap_or(U256::ZERO); - pool.tick_bitmap.insert(word, fresh_bitmap); - - if fresh_bitmap != old_bitmap { - words_changed += 1; - // Bitmap changed: re-fetch all initialized ticks for this word - let word_ticks = - extract_ticks_from_bitmap_word(word as i32, fresh_bitmap, pool.tick_spacing); - ticks_to_fetch.extend_from_slice(&word_ticks); - } - // Unchanged words: keep existing pool.ticks entries (no re-fetch needed) - } - - // Fetch tick info via raw storage reads (slots 0+3 only -- 2 reads per tick) - // Raw reads populate BlockchainDb directly, so EVM SLOADs will find fresh values - if !ticks_to_fetch.is_empty() { - let fresh_ticks = - fetch_tick_info_raw_storage(cache, pool.address, &ticks_to_fetch, flavor, false)?; - for (tick, info) in fresh_ticks { - pool.ticks.insert(tick, info); - } - } - - // Update the tick snapshot with fresh data - save_v3_tick_snapshot(cache, pool); - - let total_ms = fn_start.elapsed().as_millis(); - debug!( - pool = %pool.address, - hot_zone_words = hot_zone_words.len(), - words_changed, - ticks_refreshed = ticks_to_fetch.len(), - total_ms, - "refreshed V3 state with hot-zone tick data" - ); - - Ok(()) -} - -/// Targeted resync of V3 ticks affected by Mint/Burn events. -/// -/// Instead of scanning the entire hot zone (7+ bitmap words), this function: -/// 1. Refreshes slot0 + liquidity (Mint/Burn can change active liquidity) -/// 2. Extends tick region if the tick drifted to an unfetched bitmap word -/// 3. Computes the bitmap words that overlap the affected tick ranges -/// 4. Purges and re-fetches only those specific bitmap words -/// 5. Re-fetches tick info for all initialized ticks in affected words -/// (Mint/Burn changes liquidityNet even when the bitmap bit stays set) -/// 6. Removes ticks that are no longer initialized (full Burn) -/// 7. Persists tick snapshot and injects into EVM cache -/// -/// The `affected_tick_ranges` are `(tick_lower, tick_upper)` pairs from -/// Mint/Burn events. Most LP positions span only 1-3 bitmap words, so this -/// is dramatically cheaper than the full hot-zone resync. -#[instrument(skip(cache, affected_tick_ranges), fields(pool = %pool.address, ranges = affected_tick_ranges.len()))] -pub fn targeted_tick_resync( - cache: &mut EvmCache, - pool: &mut UniswapV3Pool, - flavor: V3Flavor, - affected_tick_ranges: &[(i32, i32)], -) -> Result<()> { - targeted_tick_resync_inner(cache, pool, flavor, affected_tick_ranges, false) -} - -/// Targeted tick resync that optionally skips the slot0+liquidity RPC refresh. -/// -/// When `skip_slot0_refresh` is true, assumes slot0 and liquidity have already been -/// injected into the EVM cache (e.g., via `inject_hot_state_to_evm`), saving 1 RPC call. -pub fn targeted_tick_resync_with_injected_slot0( - cache: &mut EvmCache, - pool: &mut UniswapV3Pool, - flavor: V3Flavor, - affected_tick_ranges: &[(i32, i32)], -) -> Result<()> { - targeted_tick_resync_inner(cache, pool, flavor, affected_tick_ranges, true) -} - -fn targeted_tick_resync_inner( - cache: &mut EvmCache, - pool: &mut UniswapV3Pool, - flavor: V3Flavor, - affected_tick_ranges: &[(i32, i32)], - skip_slot0_refresh: bool, -) -> Result<()> { - if pool.tick_spacing == 0 || affected_tick_ranges.is_empty() { - return Ok(()); - } - - let fn_start = Instant::now(); - - // Step 0: Refresh slot0 + liquidity (skip if already injected from hot state) - if !skip_slot0_refresh { - refresh_v3_slot0(cache, pool, flavor)?; - } - - // Step 0b: Check if tick moved to an unfetched bitmap word - if needs_tick_resync(pool, pool.tick) { - extend_v3_tick_region(cache, pool, flavor)?; - } - - // Step 1: Compute affected bitmap words from all pending tick ranges - let affected_words = - collect_targeted_tick_words(pool.tick, pool.tick_spacing, affected_tick_ranges); - - let bitmap_base = flavor.tick_bitmap_base_slot(); - - // Step 2: Purge affected bitmap storage slots - let bitmap_slots: Vec = affected_words - .iter() - .map(|&w| v3_tick_bitmap_storage_key_with_base(w, bitmap_base)) - .collect(); - cache.purge_pool_slots(pool.address, &bitmap_slots); - - // Step 3: Fetch fresh bitmap values and extract initialized ticks - let mut words_changed = 0; - let mut ticks_to_fetch: Vec = Vec::new(); - - for &word in &affected_words { - let slot = v3_tick_bitmap_storage_key_with_base(word, bitmap_base); - let fresh_bitmap = cache.read_storage_slot(pool.address, slot)?; - let old_bitmap = pool.tick_bitmap.get(&word).copied().unwrap_or(U256::ZERO); - pool.tick_bitmap.insert(word, fresh_bitmap); - - if fresh_bitmap != old_bitmap { - words_changed += 1; - } - - // Always re-fetch all initialized ticks in affected words because - // Mint/Burn changes liquidityNet even when the bitmap bit stays set - if fresh_bitmap != U256::ZERO { - let word_ticks = - extract_ticks_from_bitmap_word(word as i32, fresh_bitmap, pool.tick_spacing); - ticks_to_fetch.extend_from_slice(&word_ticks); - } - } - - // Step 4: Fetch fresh tick info - if !ticks_to_fetch.is_empty() { - let fresh_ticks = - fetch_tick_info_raw_storage(cache, pool.address, &ticks_to_fetch, flavor, false)?; - for (tick, info) in fresh_ticks { - pool.ticks.insert(tick, info); - } - } - - // Step 5: Remove ticks that are no longer initialized in affected words - // (Burn can remove a position entirely, making a tick no longer initialized) - let initialized_in_affected: HashSet = ticks_to_fetch.into_iter().collect(); - pool.ticks.retain(|&tick, _| { - let tick_word = tick_to_word(tick, pool.tick_spacing) as i16; - if affected_words.contains(&tick_word) { - // Tick is in an affected word — keep only if still initialized - initialized_in_affected.contains(&tick) - } else { - // Tick is outside affected words — unchanged, keep it - true - } - }); - - // Step 6: Persist and inject - save_v3_tick_snapshot(cache, pool); - inject_v3_tick_data(cache, pool.address, pool, flavor); - - let total_ms = fn_start.elapsed().as_millis(); - debug!( - pool = %pool.address, - ranges = affected_tick_ranges.len(), - affected_words = affected_words.len(), - words_changed, - ticks_refreshed = initialized_in_affected.len(), - total_ms, - "targeted V3 tick resync complete" - ); - - Ok(()) -} - -// ============================================================================ -// Parallel Prefetch Functions for V3 Pools -// ============================================================================ - -/// Result of a batch storage slot fetch, including per-address failure counts. -pub(super) struct BatchFetchResult { - pub success_count: usize, - pub error_count: usize, - /// Number of failed slot fetches per address (only addresses with errors). - pub errors_by_address: HashMap, - /// One representative error string per address. - pub error_samples_by_address: HashMap, -} - -/// Per-pool prefetch error stats returned by V3 prefetch helpers. -pub struct V3PrefetchStats { - /// Number of failed slot fetches per pool address. - pub errors_by_pool: HashMap, - /// Total storage slots requested per pool address. - pub total_requested_by_pool: HashMap, - /// One representative error string per pool address. - pub error_samples_by_pool: HashMap, -} - -/// Batch-fetch storage slots using direct RPC calls when available, -/// falling back to SharedBackend-based adaptive prefetch. -/// -/// When the batch fetcher is available, this bypasses SharedBackend's -/// per-request channel overhead and fires concurrent `eth_getStorageAt` -/// calls directly via the provider, injecting results into BlockchainDb in bulk. -/// This reduces 16K+ individual requests into ~160 concurrent batches of 100. -/// -/// Failed slots are retried up to 2 times with exponential backoff to recover -/// transient 429s before they cascade to serial SharedBackend fallback. -async fn batch_fetch_storage_slots( - cache: &mut EvmCache, - requests: &[(Address, U256)], - progress_label: &str, -) -> BatchFetchResult { - if requests.is_empty() { - return BatchFetchResult { - success_count: 0, - error_count: 0, - errors_by_address: HashMap::new(), - error_samples_by_address: HashMap::new(), - }; - } - - if let Some(fetcher) = cache.storage_batch_fetcher().cloned() { - const BATCH_RETRY_ATTEMPTS: usize = 2; - const BATCH_RETRY_BASE_DELAY_MS: u64 = 500; - - let pb = progress_bar(requests.len() as u64, progress_label); - let mut all_successes: Vec<(Address, U256, U256)> = Vec::with_capacity(requests.len()); - let mut pending: Vec<(Address, U256)> = requests.to_vec(); - let mut total_errors = 0usize; - let mut last_failures: Vec<(Address, U256, String)> = Vec::new(); - - for attempt in 0..=BATCH_RETRY_ATTEMPTS { - if pending.is_empty() { - break; - } - if attempt > 0 { - let delay_ms = BATCH_RETRY_BASE_DELAY_MS * (1 << (attempt - 1)); - debug!( - attempt, - retry_slots = pending.len(), - delay_ms, - "retrying failed batch fetch slots" - ); - tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await; - } - - let results = fetcher(pending.clone(), None); - let mut failed_this_round: Vec<(Address, U256, String)> = Vec::new(); - - for (addr, slot, result) in results { - if attempt == 0 { - pb.inc(1); - } - match result { - Ok(value) => all_successes.push((addr, slot, value)), - Err(err) => failed_this_round.push((addr, slot, err.to_string())), - } - } - - if failed_this_round.is_empty() { - last_failures.clear(); - break; - } - total_errors = failed_this_round.len(); - pending = failed_this_round - .iter() - .map(|(addr, slot, _)| (*addr, *slot)) - .collect(); - last_failures = failed_this_round; - } - - // Build per-address error counts from remaining failures - let mut errors_by_address: HashMap = HashMap::new(); - let mut error_samples_by_address: HashMap = HashMap::new(); - for (addr, _, sample) in last_failures { - *errors_by_address.entry(addr).or_default() += 1; - error_samples_by_address.entry(addr).or_insert(sample); - } - - let success_count = all_successes.len(); - cache.inject_storage_batch(&all_successes); - finish_with_message( - &pb, - &format!("{} fetched, {} failed", success_count, total_errors), - ); - BatchFetchResult { - success_count, - error_count: total_errors, - errors_by_address, - error_samples_by_address, - } - } else { - // Fallback: existing SharedBackend path with adaptive throttling - let backend = cache - .unchecked_backend() - .with_blocking_mode(BlockingMode::Block); - let result = super::adaptive_prefetch::run_adaptive_prefetch( - requests, - super::adaptive_prefetch::AdaptivePrefetchConfig::throttle_aware(), - progress_label, - |&(addr, slot), idx| { - let backend = backend.clone(); - tokio::task::spawn_blocking(move || { - backend.storage_ref(addr, slot).map(|_| ()).map_err(|_| idx) - }) - }, - ) - .await; - BatchFetchResult { - success_count: result.success_count, - error_count: result.error_count, - errors_by_address: HashMap::new(), - error_samples_by_address: HashMap::new(), - } - } -} - -/// Prefetch all tick bitmap storage slots for V3 pools that need a full resync. -/// -/// For each pool in `targets`, computes the storage slot keys for all bitmap words -/// in the adaptive scan range (+-max_scan_words from center_tick), then fetches them -/// all in parallel via cloned SharedBackend. Results auto-populate BlockchainDb so -/// that subsequent `sync_uniswap_v3_ticks` calls find the values cached and complete -/// without individual RPC round-trips. -/// -/// This is the key optimization for cold start: instead of ~13,600 sequential RPC calls -/// (~22 minutes), we fetch them in parallel via adaptive chunked prefetch. -#[instrument(skip(cache, targets), fields(pool_count = targets.len()))] -pub async fn prefetch_v3_bitmap_slots( - cache: &mut EvmCache, - targets: &[V3BitmapPrefetchTarget], -) -> Result<()> { - if targets.is_empty() { - return Ok(()); - } - - // Compute all (address, storage_slot) pairs needed across all pools - let mut fetch_requests: Vec<(Address, U256)> = Vec::new(); - - for target in targets { - let current_word = tick_to_word(target.center_tick, target.tick_spacing); - let min_word = tick_to_word(MIN_TICK, target.tick_spacing); - let max_word = tick_to_word(MAX_TICK, target.tick_spacing); - let bitmap_base = target.flavor.tick_bitmap_base_slot(); - - // Positive direction (including current_word) - for offset in 0..=target.max_scan_words { - let word = current_word + offset; - if word > max_word { - break; - } - let slot = v3_tick_bitmap_storage_key_with_base(word as i16, bitmap_base); - fetch_requests.push((target.address, slot)); - } - - // Negative direction - for offset in 1..=target.max_scan_words { - let word = current_word - offset; - if word < min_word { - break; - } - let slot = v3_tick_bitmap_storage_key_with_base(word as i16, bitmap_base); - fetch_requests.push((target.address, slot)); - } - } - - let total_slots = fetch_requests.len(); - if total_slots == 0 { - return Ok(()); - } - - info!( - pools = targets.len(), - total_slots, "prefetching V3 bitmap storage slots in parallel" - ); - - let result = batch_fetch_storage_slots(cache, &fetch_requests, "Prefetching V3 bitmaps").await; - - debug!( - result.success_count, - result.error_count, total_slots, "V3 bitmap slot prefetch complete" - ); - - Ok(()) -} - -/// Prefetch V3 tick info storage slots in parallel. -/// -/// After bitmap slots have been prefetched (via `prefetch_v3_bitmap_slots`), this function: -/// 1. Reads the cached bitmap words from BlockchainDb (instant, no RPC) -/// 2. Extracts initialized tick indices from the bitmaps -/// 3. Computes all 4 tick info storage slot keys per tick -/// 4. Prefetches them in parallel using spawn_blocking -/// -/// This ensures that the subsequent `fetch_tick_info_batched` Multicall3 execution -/// finds all tick info SLOADs already cached in BlockchainDb. -#[instrument(skip(cache, targets), fields(pool_count = targets.len()))] -pub async fn prefetch_v3_tick_info_slots( - cache: &mut EvmCache, - targets: &[V3BitmapPrefetchTarget], -) -> Result { - if targets.is_empty() { - return Ok(V3PrefetchStats { - errors_by_pool: HashMap::new(), - total_requested_by_pool: HashMap::new(), - error_samples_by_pool: HashMap::new(), - }); - } - - let backend = cache - .unchecked_backend() - .with_blocking_mode(BlockingMode::Block); - - // Phase 1: Read cached bitmaps (with early termination) and extract initialized ticks. - // Only slots 0 and 3 per tick are needed (liquidityGross/Net + initialized flag). - let mut tick_info_requests: Vec<(Address, U256)> = Vec::new(); - let mut total_ticks = 0usize; - let mut total_requested_by_pool: HashMap = HashMap::new(); - - for target in targets { - let center_word = tick_to_word(target.center_tick, target.tick_spacing); - let min_word = tick_to_word(MIN_TICK, target.tick_spacing); - let max_word = tick_to_word(MAX_TICK, target.tick_spacing); - let bitmap_base = target.flavor.tick_bitmap_base_slot(); - let ticks_base = target.flavor.ticks_base_slot(); - - let mut pool_ticks: Vec = Vec::new(); - - // Helper: extract initialized ticks from a bitmap word - let mut extract_ticks = |word: i32, bitmap: U256| { - let limbs = bitmap.as_limbs(); - for (limb_idx, &limb) in limbs.iter().enumerate() { - if limb == 0 { - continue; - } - let base_bit = (limb_idx * 64) as i32; - let mut remaining = limb; - while remaining != 0 { - let bit_in_limb = remaining.trailing_zeros() as i32; - remaining &= remaining - 1; - let bit = base_bit + bit_in_limb; - let tick_index = (word * 256 + bit) * target.tick_spacing; - pool_ticks.push(tick_index); - } - } - }; - - // Scan positive direction with early termination (mirrors sync_uniswap_v3_ticks) - let mut consecutive_empty = 0usize; - for offset in 0..=target.max_scan_words { - let word = center_word + offset; - if word > max_word { - break; - } - let slot = v3_tick_bitmap_storage_key_with_base(word as i16, bitmap_base); - let bitmap = match backend.storage_ref(target.address, slot) { - Ok(val) => val, - Err(_) => continue, - }; - if bitmap == U256::ZERO { - consecutive_empty += 1; - if consecutive_empty >= target.empty_word_threshold { - break; - } - } else { - consecutive_empty = 0; - extract_ticks(word, bitmap); - } - } - - // Scan negative direction with early termination - consecutive_empty = 0; - for offset in 1..=target.max_scan_words { - let word = center_word - offset; - if word < min_word { - break; - } - let slot = v3_tick_bitmap_storage_key_with_base(word as i16, bitmap_base); - let bitmap = match backend.storage_ref(target.address, slot) { - Ok(val) => val, - Err(_) => continue, - }; - if bitmap == U256::ZERO { - consecutive_empty += 1; - if consecutive_empty >= target.empty_word_threshold { - break; - } - } else { - consecutive_empty = 0; - extract_ticks(word, bitmap); - } - } - - // Only slots 0 and 3 per tick (liquidityGross/Net + initialized flag) - for &tick in &pool_ticks { - let keys = v3_tick_info_storage_keys_with_base(tick, ticks_base); - tick_info_requests.push((target.address, keys[0])); - tick_info_requests.push((target.address, keys[3])); - } - let requested_slots = pool_ticks.len() * 2; - if requested_slots > 0 { - total_requested_by_pool.insert(target.address, requested_slots); - } - - total_ticks += pool_ticks.len(); - } - - if tick_info_requests.is_empty() { - info!("no initialized ticks found — skipping tick info prefetch"); - return Ok(V3PrefetchStats { - errors_by_pool: HashMap::new(), - total_requested_by_pool, - error_samples_by_pool: HashMap::new(), - }); - } - - info!( - pools = targets.len(), - total_ticks, - total_slots = tick_info_requests.len(), - "prefetching V3 tick info storage slots in parallel" - ); - - let result = - batch_fetch_storage_slots(cache, &tick_info_requests, "Prefetching V3 tick info").await; - - debug!( - result.success_count, - result.error_count, - total_ticks, - total_slots = tick_info_requests.len(), - "V3 tick info slot prefetch complete" - ); - - Ok(V3PrefetchStats { - errors_by_pool: result.errors_by_address, - total_requested_by_pool, - error_samples_by_pool: result.error_samples_by_address, - }) -} - -/// Parallel prefetch for V3 pools needing incremental resync. -/// -/// For each pool: computes the scan range (using old_bitmap for early termination), -/// purges stale bitmap storage slots, then parallel-prefetches fresh values. -/// After bitmap prefetch, reads the fresh bitmaps from cache, identifies ticks to -/// re-fetch (changed words + hot zone), and parallel-prefetches their tick info slots. -/// -/// After this function returns, `incremental_sync_v3_ticks` can be called with -/// `pre_purged=true` and all reads will hit the BlockchainDb cache. -#[instrument(skip_all, fields(pool_count = pools.len()))] -pub async fn prefetch_v3_incremental_resync_slots( - cache: &mut EvmCache, - pools: &[V3IncrementalPrefetchTarget<'_>], - // (address, flavor, tick_spacing, current_tick, liquidity, old_bitmap) -) -> Result { - if pools.is_empty() { - return Ok(V3PrefetchStats { - errors_by_pool: HashMap::new(), - total_requested_by_pool: HashMap::new(), - error_samples_by_pool: HashMap::new(), - }); - } - - let min_tick_word = |ts: i32| tick_to_word(MIN_TICK, ts); - let max_tick_word = |ts: i32| tick_to_word(MAX_TICK, ts); - - // Step 1: Compute scan ranges and collect all bitmap slot keys to purge+prefetch - let mut bitmap_requests: Vec<(Address, U256)> = Vec::new(); - // Track per-pool scan words for tick info extraction later - let mut pool_scan_words: Vec> = Vec::with_capacity(pools.len()); - - for &(address, flavor, tick_spacing, current_tick, liquidity, old_bitmap) in pools { - if tick_spacing == 0 { - pool_scan_words.push(Vec::new()); - continue; - } - let current_word = tick_to_word(current_tick, tick_spacing); - let min_word = min_tick_word(tick_spacing); - let max_word = max_tick_word(tick_spacing); - let bitmap_base = flavor.tick_bitmap_base_slot(); - let scan_params = compute_adaptive_scan_params(liquidity, tick_spacing); - - let mut words_to_scan = Vec::new(); - - // Positive direction with early termination using old_bitmap - let mut consecutive_empty = 0usize; - for offset in 0..=scan_params.max_scan_words { - let word = current_word + offset; - if word > max_word { - break; - } - words_to_scan.push(word as i16); - let cached_val = old_bitmap - .get(&(word as i16)) - .copied() - .unwrap_or(U256::ZERO); - if cached_val == U256::ZERO { - consecutive_empty += 1; - if consecutive_empty >= scan_params.empty_word_threshold { - break; - } - } else { - consecutive_empty = 0; - } - } - - // Negative direction - consecutive_empty = 0; - for offset in 1..=scan_params.max_scan_words { - let word = current_word - offset; - if word < min_word { - break; - } - words_to_scan.push(word as i16); - let cached_val = old_bitmap - .get(&(word as i16)) - .copied() - .unwrap_or(U256::ZERO); - if cached_val == U256::ZERO { - consecutive_empty += 1; - if consecutive_empty >= scan_params.empty_word_threshold { - break; - } - } else { - consecutive_empty = 0; - } - } - - // Collect bitmap slot keys - for &w in &words_to_scan { - let slot = v3_tick_bitmap_storage_key_with_base(w, bitmap_base); - bitmap_requests.push((address, slot)); - } - - pool_scan_words.push(words_to_scan); - } - - if bitmap_requests.is_empty() { - return Ok(V3PrefetchStats { - errors_by_pool: HashMap::new(), - total_requested_by_pool: HashMap::new(), - error_samples_by_pool: HashMap::new(), - }); - } - - // Step 2: Purge stale bitmap slots - { - let mut purge_by_address: HashMap> = HashMap::new(); - for &(addr, slot) in &bitmap_requests { - purge_by_address.entry(addr).or_default().push(slot); - } - for (addr, slots) in &purge_by_address { - cache.purge_pool_slots(*addr, slots); - } - } - - // Step 3: Parallel-prefetch fresh bitmap values - let total_bitmap_slots = bitmap_requests.len(); - info!( - pools = pools.len(), - total_bitmap_slots, "prefetching incremental resync bitmap slots in parallel" - ); - - let _bitmap_result = - batch_fetch_storage_slots(cache, &bitmap_requests, "Prefetching incremental bitmaps").await; - - let backend = cache - .unchecked_backend() - .with_blocking_mode(BlockingMode::Block); - - // Step 4: Read cached fresh bitmaps and identify ticks to prefetch - // Compare fresh vs old bitmaps, extract ticks from changed words + hot zone - let mut tick_info_requests: Vec<(Address, U256)> = Vec::new(); - let mut total_ticks = 0usize; - let mut pool_errors: HashMap = HashMap::new(); - let mut total_requested_by_pool: HashMap = HashMap::new(); - let mut pool_error_samples: HashMap = HashMap::new(); - - for (i, &(address, flavor, tick_spacing, current_tick, _liquidity, old_bitmap)) in - pools.iter().enumerate() - { - if tick_spacing == 0 { - continue; - } - let bitmap_base = flavor.tick_bitmap_base_slot(); - let ticks_base = flavor.ticks_base_slot(); - let current_word = tick_to_word(current_tick, tick_spacing); - let mut pool_ticks: Vec = Vec::new(); - - for &word in &pool_scan_words[i] { - let slot = v3_tick_bitmap_storage_key_with_base(word, bitmap_base); - let fresh_bitmap = match backend.storage_ref(address, slot) { - Ok(val) => val, - Err(_) => continue, - }; - - let cached_val = old_bitmap.get(&word).copied().unwrap_or(U256::ZERO); - let word_i32 = word as i32; - let in_hot_zone = - (word_i32 - current_word).unsigned_abs() < incremental_hot_zone_radius() as u32; - - // Re-fetch ticks for hot zone words and changed words - if in_hot_zone || fresh_bitmap != cached_val { - let limbs = fresh_bitmap.as_limbs(); - for (limb_idx, &limb) in limbs.iter().enumerate() { - if limb == 0 { - continue; - } - let base_bit = (limb_idx * 64) as i32; - let mut remaining = limb; - while remaining != 0 { - let bit_in_limb = remaining.trailing_zeros() as i32; - remaining &= remaining - 1; - let bit = base_bit + bit_in_limb; - let tick_index = (word_i32 * 256 + bit) * tick_spacing; - pool_ticks.push(tick_index); - } - } - } - } - - // Only slots 0 and 3 per tick - let pool_slot_count = pool_ticks.len() * 2; - for &tick in &pool_ticks { - let keys = v3_tick_info_storage_keys_with_base(tick, ticks_base); - tick_info_requests.push((address, keys[0])); - tick_info_requests.push((address, keys[3])); - } - if pool_slot_count > 0 { - *total_requested_by_pool.entry(address).or_default() += pool_slot_count; - } - total_ticks += pool_ticks.len(); - } - - // Step 4b: Purge stale tick info slots before prefetching. - // For NeedsIncrementalResync pools, old tick info from evm_state.json may still - // be cached in BlockchainDb. Purging ensures storage_ref() fetches fresh values. - if !tick_info_requests.is_empty() { - let mut tick_purge_by_address: HashMap> = HashMap::new(); - for &(addr, slot) in &tick_info_requests { - tick_purge_by_address.entry(addr).or_default().push(slot); - } - for (addr, slots) in &tick_purge_by_address { - cache.purge_pool_slots(*addr, slots); - } - } - - // Step 5: Parallel-prefetch tick info slots - if !tick_info_requests.is_empty() { - info!( - total_ticks, - total_slots = tick_info_requests.len(), - "prefetching incremental resync tick info slots in parallel" - ); - - let tick_result = batch_fetch_storage_slots( - cache, - &tick_info_requests, - "Prefetching incremental tick info", - ) - .await; - - debug!( - tick_result.success_count, - tick_result.error_count, - total_ticks, - total_slots = tick_info_requests.len(), - "incremental tick info prefetch complete" - ); - - pool_errors = tick_result.errors_by_address; - pool_error_samples = tick_result.error_samples_by_address; - } - - debug!( - bitmap_slots = total_bitmap_slots, - tick_info_ticks = total_ticks, - "incremental resync prefetch complete" - ); - - Ok(V3PrefetchStats { - errors_by_pool: pool_errors, - total_requested_by_pool, - error_samples_by_pool: pool_error_samples, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::sync::{Arc, RwLock}; - - use alloy_eips::BlockId; - use alloy_primitives::Bytes; - use alloy_primitives::hex; - use alloy_provider::{RootProvider, network::AnyNetwork}; - use alloy_rpc_client::RpcClient; - use alloy_transport::mock::Asserter; - use foundry_fork_db::{BlockchainDb, SharedBackend, cache::BlockchainDbMeta}; - use revm::primitives::hardfork::SpecId; - use revm::state::{AccountInfo, Bytecode}; - - use crate::cache_sync::sync_all_pool_state_parallel; - - use crate::amm_wrapper::LocalAMM; - use crate::cache_sync::AMMRef; - - const V3_TEST_POOL_RUNTIME_HEX: &str = "5f3560e01c80631a68650214604d57633850c7bd14601b575f80fd5b5f5460018060a01b0381169060a01c60020b905f526020525f6040525f6060525f6080525f60a052600160c05260e05ff35b6004545f5260205ff3"; - - async fn setup_mock_cache() -> (EvmCache, Asserter) { - let asserter = Asserter::new(); - let client = RpcClient::mocked(asserter.clone()); - let provider = Arc::new(RootProvider::::new(client)); - let blockchain_db = BlockchainDb::new(BlockchainDbMeta::default(), None); - let backend = SharedBackend::spawn_backend(provider, blockchain_db.clone(), None).await; - ( - EvmCache::from_backend( - backend, - blockchain_db, - BlockId::latest(), - 42161, - None, - None, - SpecId::CANCUN, - ), - asserter, - ) - } - - fn install_stub_v3_pool(cache: &mut EvmCache, pool: Address, token0: Address, token1: Address) { - let runtime = Bytecode::new_raw(Bytes::from( - hex::decode(V3_TEST_POOL_RUNTIME_HEX).expect("valid V3 test runtime"), - )); - let code_hash = runtime.hash_slow(); - cache - .db_mut() - .insert_account_info(Address::ZERO, AccountInfo::default()); - cache - .db_mut() - .insert_account_info(token0, AccountInfo::default()); - cache - .db_mut() - .insert_account_info(token1, AccountInfo::default()); - cache.db_mut().insert_account_info( - pool, - AccountInfo { - balance: U256::ZERO, - nonce: 0, - code: Some(runtime), - code_hash, - account_id: None, - }, - ); - } - - fn seed_backend_storage(cache: &mut EvmCache, address: Address, slot: U256, value: U256) { - cache.with_blockchain_db_mut(|db| { - db.storage() - .write() - .entry(address) - .or_default() - .insert(slot, value); - }); - } - - fn seed_v3_metadata(cache: &mut EvmCache, address: Address, token0: Address, token1: Address) { - cache.immutable_cache_mut().set_v3_pool( - address, - V3PoolMetadata { - token0, - token1, - fee: 3000, - tick_spacing: 60, - }, - ); - } - - fn encode_v3_slot0(sqrt_price: U256, tick: i32) -> U256 { - let tick_bits = U256::from((tick as u32 & 0x00FF_FFFF) as u64); - sqrt_price | (tick_bits << 160) - } - - fn encode_tick_info_slots(info: &Info) -> (U256, U256) { - let slot0 = - U256::from(info.liquidity_gross) | (U256::from(info.liquidity_net as u128) << 128); - let slot3 = if info.initialized { - U256::from(1u64) << 248 - } else { - U256::ZERO - }; - (slot0, slot3) - } - - fn bitmap_word_for_ticks(ticks: &[i32], tick_spacing: i32) -> (i16, U256) { - let word = tick_to_word(*ticks.first().expect("at least one tick"), tick_spacing) as i16; - let mut bitmap = U256::ZERO; - for &tick in ticks { - assert_eq!(tick_to_word(tick, tick_spacing) as i16, word); - let compressed = tick.div_euclid(tick_spacing); - let bit = compressed.rem_euclid(256) as u32; - bitmap |= U256::from(1u64) << bit; - } - (word, bitmap) - } - - fn assert_tick_info_eq(actual: &Info, expected: &Info) { - assert_eq!(actual.liquidity_gross, expected.liquidity_gross); - assert_eq!(actual.liquidity_net, expected.liquidity_net); - assert_eq!(actual.initialized, expected.initialized); - } - - fn make_test_pool( - address: Address, - token0: Address, - token1: Address, - tick: i32, - liquidity: u128, - tick_bitmap: HashMap, - ticks: HashMap, - ) -> UniswapV3Pool { - UniswapV3Pool { - address, - token_a: Token::new_with_decimals(token0, 18), - token_b: Token::new_with_decimals(token1, 18), - fee: 3000, - tick_spacing: 60, - sqrt_price: U256::from(1_000u64), - tick, - liquidity, - tick_bitmap, - ticks, - } - } - - #[test] - fn test_can_reuse_v3_tick_snapshot_when_liquidity_matches() { - assert!(can_reuse_v3_tick_snapshot(1_000, 1_000, 12_000, 12_900)); - assert!(can_reuse_v3_tick_snapshot( - 1_000, - 1_000, - 12_000, - 12_000 + MAX_TICK_DRIFT_FOR_CACHE - )); - } - - #[test] - fn test_cannot_reuse_v3_tick_snapshot_on_liquidity_or_large_tick_drift() { - assert!(!can_reuse_v3_tick_snapshot(1_000, 999, 12_000, 12_000)); - assert!(!can_reuse_v3_tick_snapshot( - 1_000, - 1_000, - 12_000, - 12_001 + MAX_TICK_DRIFT_FOR_CACHE - )); - } - - #[test] - fn test_collect_targeted_tick_words_includes_current_word_and_deduplicates() { - let words = collect_targeted_tick_words(120, 60, &[(60, 120), (120, 240), (60, 120)]); - - let expected: HashSet = [ - tick_to_word(60, 60) as i16, - tick_to_word(120, 60) as i16, - tick_to_word(240, 60) as i16, - ] - .into_iter() - .collect(); - - assert_eq!(words, expected); - } - - #[test] - fn test_collect_targeted_tick_words_handles_negative_ranges() { - let words = collect_targeted_tick_words(-180, 60, &[(-360, -60)]); - - assert!(words.contains(&(tick_to_word(-360, 60) as i16))); - assert!(words.contains(&(tick_to_word(-180, 60) as i16))); - assert!(words.contains(&(tick_to_word(-60, 60) as i16))); - } - - #[tokio::test(flavor = "multi_thread")] - async fn test_init_uniswap_v3_from_cache_reuses_snapshot_without_bitmap_refresh() -> Result<()> - { - let (mut cache, asserter) = setup_mock_cache().await; - let pool_address = Address::repeat_byte(0x11); - let token0 = Address::repeat_byte(0x22); - let token1 = Address::repeat_byte(0x33); - let current_tick = 90; - let live_tick = 95; - let live_liquidity = 500u128; - - install_stub_v3_pool(&mut cache, pool_address, token0, token1); - seed_v3_metadata(&mut cache, pool_address, token0, token1); - - let (word, bitmap) = bitmap_word_for_ticks(&[60, 120], 60); - let mut cached_bitmap = HashMap::new(); - cached_bitmap.insert(word, bitmap); - - let mut cached_ticks = HashMap::new(); - cached_ticks.insert(60, Info::new(111, 11, true)); - cached_ticks.insert(120, Info::new(222, -22, true)); - - cache.tick_snapshot_cache_mut().set( - pool_address, - V3PoolTickSnapshot::from_pool_data( - &cached_bitmap, - &info_ticks_to_cache(&cached_ticks), - live_liquidity, - current_tick, - ), - ); - seed_backend_storage( - &mut cache, - pool_address, - v3_tick_bitmap_storage_key_with_base(word, V3_TICK_BITMAP_BASE_SLOT), - bitmap, - ); - - asserter.push_success(&encode_v3_slot0(U256::from(2_000u64), live_tick)); - asserter.push_success(&U256::from(live_liquidity)); - - let pool = init_uniswap_v3_from_cache(&mut cache, pool_address).await?; - - assert_eq!(pool.tick, live_tick); - assert_eq!(pool.liquidity, live_liquidity); - assert_eq!(pool.tick_bitmap.len(), 1); - assert_eq!(pool.tick_bitmap.get(&word), Some(&bitmap)); - assert_eq!(pool.ticks.len(), 2); - assert_tick_info_eq(pool.ticks.get(&60).unwrap(), cached_ticks.get(&60).unwrap()); - assert_tick_info_eq( - pool.ticks.get(&120).unwrap(), - cached_ticks.get(&120).unwrap(), - ); - assert!(asserter.read_q().is_empty()); - - Ok(()) - } - - #[tokio::test(flavor = "multi_thread")] - async fn test_targeted_tick_resync_updates_only_affected_words_and_ticks() -> Result<()> { - let (mut cache, asserter) = setup_mock_cache().await; - let pool_address = Address::repeat_byte(0x44); - let token0 = Address::repeat_byte(0x55); - let token1 = Address::repeat_byte(0x66); - - install_stub_v3_pool(&mut cache, pool_address, token0, token1); - - let (word, old_bitmap) = bitmap_word_for_ticks(&[60, 120], 60); - let mut tick_bitmap = HashMap::new(); - tick_bitmap.insert(word, old_bitmap); - - let old_tick_60 = Info::new(111, 11, true); - let old_tick_120 = Info::new(222, -22, true); - let mut ticks = HashMap::new(); - ticks.insert(60, old_tick_60); - ticks.insert(120, old_tick_120); - - let mut pool = make_test_pool(pool_address, token0, token1, 90, 500, tick_bitmap, ticks); - let (_, new_bitmap) = bitmap_word_for_ticks(&[120], 60); - let new_tick_120 = Info::new(333, -44, true); - let (tick_slot0, tick_slot3) = encode_tick_info_slots(&new_tick_120); - - asserter.push_success(&encode_v3_slot0(U256::from(3_000u64), 90)); - asserter.push_success(&U256::from(600u128)); - asserter.push_success(&new_bitmap); - asserter.push_success(&tick_slot0); - asserter.push_success(&tick_slot3); - - targeted_tick_resync(&mut cache, &mut pool, V3Flavor::UniswapV3, &[(60, 120)])?; - - assert_eq!(pool.liquidity, 600); - assert_eq!(pool.sqrt_price, U256::from(3_000u64)); - assert_eq!(pool.tick_bitmap.get(&word), Some(&new_bitmap)); - assert!(!pool.ticks.contains_key(&60)); - assert_eq!(pool.ticks.len(), 1); - assert_tick_info_eq(pool.ticks.get(&120).unwrap(), &new_tick_120); - - let snapshot = cache.tick_snapshot_cache().get(pool_address).unwrap(); - assert_eq!(snapshot.last_liquidity, 600); - assert_eq!(snapshot.last_tick, 90); - assert_eq!(snapshot.ticks.len(), 1); - assert!(asserter.read_q().is_empty()); - - Ok(()) - } - - #[tokio::test(flavor = "multi_thread")] - async fn test_sync_all_pool_state_parallel_skips_v3_hot_zone_when_liquidity_matches() - -> Result<()> { - let (mut cache, asserter) = setup_mock_cache().await; - let pool_address = Address::repeat_byte(0x77); - let token0 = Address::repeat_byte(0x88); - let token1 = Address::repeat_byte(0x99); - let current_tick = 90; - let new_tick = 95; - - let (word, bitmap) = bitmap_word_for_ticks(&[60, 120], 60); - let mut tick_bitmap = HashMap::new(); - tick_bitmap.insert(word, bitmap); - - let mut ticks = HashMap::new(); - ticks.insert(60, Info::new(111, 11, true)); - ticks.insert(120, Info::new(222, -22, true)); - - let pool = make_test_pool( - pool_address, - token0, - token1, - current_tick, - 500, - tick_bitmap.clone(), - ticks.clone(), - ); - - asserter.push_success(&encode_v3_slot0(U256::from(4_000u64), new_tick)); - asserter.push_success(&U256::from(500u128)); - - let amm_ref: AMMRef = Arc::new(RwLock::new(LocalAMM::UniswapV3(pool))); - let mut amms = HashMap::new(); - amms.insert(pool_address, amm_ref.clone()); - - sync_all_pool_state_parallel(&mut cache, &mut amms).await?; - - let guard = amm_ref.read().unwrap(); - let refreshed = match &*guard { - LocalAMM::UniswapV3(pool) => pool, - other => panic!("unexpected AMM type after refresh: {other:?}"), - }; - assert_eq!(refreshed.tick, new_tick); - assert_eq!(refreshed.liquidity, 500); - assert_eq!(refreshed.sqrt_price, U256::from(4_000u64)); - assert_eq!(refreshed.tick_bitmap.get(&word), Some(&bitmap)); - assert_eq!(refreshed.ticks.len(), 2); - assert_tick_info_eq(refreshed.ticks.get(&60).unwrap(), ticks.get(&60).unwrap()); - assert_tick_info_eq(refreshed.ticks.get(&120).unwrap(), ticks.get(&120).unwrap()); - - let snapshot = cache.tick_snapshot_cache().get(pool_address).unwrap(); - assert_eq!(snapshot.last_liquidity, 500); - assert_eq!(snapshot.last_tick, new_tick); - assert!(asserter.read_q().is_empty()); - - Ok(()) - } -} diff --git a/src/configured_amms.rs b/src/configured_amms.rs deleted file mode 100644 index 2ce36ce..0000000 --- a/src/configured_amms.rs +++ /dev/null @@ -1,1406 +0,0 @@ -//! AMM configuration loading and lazy V3 tick initialization. -//! -//! An AMM set is described by a list of [`AmmConfigEntry`] records. Build them -//! programmatically (see [`AmmConfigEntry::new`]) or, with the `toml` feature, -//! parse them from an `amms.toml` file. Either way they are initialized into -//! [`crate::amm_wrapper::LocalAMM`] pools backed by an -//! [`evm_fork_cache::cache::EvmCache`], optionally filtered by token relevance -//! first ([`filter_amm_entries_by_tokens`]). -//! -//! Two loading paths are provided, each with a TOML-file variant (feature -//! `toml`) and a TOML-free `*_from_entries` variant: -//! - [`load_configured_amms_from_entries`] fully initializes every pool -//! (including the expensive V3 tick scan) in one call. -//! - [`load_configured_amms_lazy_from_entries`] initializes V2/Balancer pools -//! and V3 metadata, deferring V3 tick prefetch into a [`DeferredV3Work`] -//! handle that [`complete_deferred_v3_work`] finishes later. This lets a -//! caller load only the pools it currently needs before paying the tick cost. - -use alloy_primitives::{Address, B256, U256}; -use serde::Deserialize; -#[cfg(feature = "toml")] -use std::path::Path; -use std::{ - collections::{HashMap, HashSet}, - time::Instant, -}; -use tracing::{debug, info, warn}; - -use crate::cache_sync::compat::EvmCacheProtocolExt; -use crate::cache_sync::{ - V3BitmapPrefetchTarget, V3Flavor, V3InitPhase1Result, V3PrefetchStats, - compute_adaptive_scan_params, incremental_sync_v3_ticks, init_balancer_from_cache, - init_uniswap_v2_from_cache, init_v3_phase1, inject_v3_tick_data, prefetch_accounts, - prefetch_v3_bitmap_slots, prefetch_v3_incremental_resync_slots, prefetch_v3_tick_info_slots, - save_v3_tick_snapshot, sync_uniswap_v3_ticks, -}; -use crate::progress::{finish_with_message, progress_bar}; -use crate::{ - amm_wrapper::LocalAMM, balancer_pool::BalancerPool, balancer_v3_pool::BalancerV3PoolType, - slipstream_pool::SlipstreamPool, -}; -use evm_fork_cache::cache::EvmCache; - -/// Wrapper function type for converting a V3 pool into the appropriate LocalAMM variant. -/// Uses Box because Slipstream wrappers capture tick_spacing. -type WrapperFn = Box LocalAMM>; -type DeferredV3Resync = ( - Address, - amms::amms::uniswap_v3::UniswapV3Pool, - V3Flavor, - WrapperFn, -); -type DeferredV3Incremental = ( - Address, - amms::amms::uniswap_v3::UniswapV3Pool, - V3Flavor, - std::collections::HashMap, - std::collections::HashMap, - WrapperFn, -); - -/// V3 pools whose tick data was deferred during lazy AMM loading. -/// Call `complete_deferred_v3_work()` to finish their initialization. -pub struct DeferredV3Work { - needs_resync: Vec, - needs_incremental: Vec, -} - -impl Default for DeferredV3Work { - fn default() -> Self { - Self::new() - } -} - -impl DeferredV3Work { - /// Create an empty DeferredV3Work. - pub fn new() -> Self { - Self { - needs_resync: Vec::new(), - needs_incremental: Vec::new(), - } - } - - /// Returns true if there's no deferred V3 work to complete. - pub fn is_empty(&self) -> bool { - self.needs_resync.is_empty() && self.needs_incremental.is_empty() - } - - /// Returns the total number of deferred V3 pools. - pub fn len(&self) -> usize { - self.needs_resync.len() + self.needs_incremental.len() - } - - /// Add a V3 pool that needs full bitmap resync (cold start). - pub fn push_resync( - &mut self, - address: Address, - pool: amms::amms::uniswap_v3::UniswapV3Pool, - flavor: V3Flavor, - wrapper: impl Fn(amms::amms::uniswap_v3::UniswapV3Pool) -> LocalAMM + 'static, - ) { - self.needs_resync - .push((address, pool, flavor, Box::new(wrapper))); - } - - /// Add a V3 pool that needs incremental resync (stale snapshot). - pub fn push_incremental( - &mut self, - address: Address, - pool: amms::amms::uniswap_v3::UniswapV3Pool, - flavor: V3Flavor, - old_bitmap: std::collections::HashMap, - old_ticks: std::collections::HashMap, - wrapper: impl Fn(amms::amms::uniswap_v3::UniswapV3Pool) -> LocalAMM + 'static, - ) { - self.needs_incremental.push(( - address, - pool, - flavor, - old_bitmap, - old_ticks, - Box::new(wrapper), - )); - } -} - -fn is_non_archive_state_error(message: &str) -> bool { - let normalized = message.to_ascii_lowercase(); - normalized.contains("missing trie node") - || normalized.contains("non-archive node") - || normalized.contains("state ") && normalized.contains("is not available, not found") -} - -fn v3_prefetch_skip_threshold(total_requested: usize) -> usize { - if total_requested > 0 { - (total_requested * 3 / 4).max(200) - } else { - 200 - } -} - -fn v3_prefetch_skip_reason(stats: &V3PrefetchStats, address: Address) -> Option<&'static str> { - let errors = stats.errors_by_pool.get(&address).copied().unwrap_or(0); - if errors == 0 { - return None; - } - - if stats - .error_samples_by_pool - .get(&address) - .is_some_and(|sample| is_non_archive_state_error(sample)) - { - return Some("required historical storage is unavailable on the current RPC"); - } - - let total_requested = stats - .total_requested_by_pool - .get(&address) - .copied() - .unwrap_or(0); - if errors > v3_prefetch_skip_threshold(total_requested) { - return Some("too many tick slots failed prefetch and would trigger serial RPC fallback"); - } - - None -} - -/// AMM family for a configured pool entry. -/// -/// Deserialized from the TOML `type` field using snake_case names -/// (e.g. `uniswap_v2`, `pancake_swap_v3`, `balancer_v3`). The variant selects -/// which loader is used to initialize the pool and which [`crate::amm_wrapper::LocalAMM`] -/// variant the entry becomes. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum AmmType { - UniswapV2, - UniswapV3, - PancakeSwapV3, - Balancer, - BalancerV3, - Curve, - SolidlyV2, - Slipstream, - UniswapV4, -} - -/// A single AMM entry parsed from the `amms.toml` config. -/// -/// `kind` and `address` are always required; the remaining fields are optional -/// and only consulted for the AMM families that need them (e.g. `pool_id` for -/// Balancer V2, `tick_spacing` for Slipstream/Uniswap V4, `stable` and -/// `factory_address` for Solidly V2, `curve_use_uint256` for Curve). Fields -/// irrelevant to a given `kind` are ignored. -#[derive(Debug, Clone, Deserialize)] -pub struct AmmConfigEntry { - #[serde(rename = "type")] - pub kind: AmmType, - pub address: Address, - #[serde(default)] - pub tokens: Vec
, - #[serde(default)] - pub fee_tier: Option, - #[serde(default)] - pub vault_address: Option
, - #[serde(default)] - pub pool_id: Option, - /// Tick spacing for Slipstream and UniswapV4 pools. - #[serde(default)] - pub tick_spacing: Option, - /// Whether this is a stable (true) or volatile (false) pool (SolidlyV2). - #[serde(default)] - pub stable: Option, - /// Factory address for SolidlyV2/Slipstream pools. - #[serde(default)] - pub factory_address: Option
, - /// Hooks address for UniswapV4 pools. - #[serde(default)] - pub hooks: Option
, - /// `false` = stableswap (int128 indices), `true` = cryptoswap (uint256 indices). - #[serde(default)] - pub curve_use_uint256: Option, - /// Balancer V3 pool type hint: `stable` or `weighted`. - /// When set, avoids trial-and-error RPC calls to detect pool type. - /// Falls back to auto-detection if omitted. - #[serde(default)] - pub balancer_v3_pool_type: Option, -} - -impl AmmConfigEntry { - /// Create a minimal entry for `kind` at `address`, with every optional - /// field unset. Chain the `with_*` builders to fill in the fields a given - /// family needs. - /// - /// This is the programmatic alternative to parsing `amms.toml`: nothing in - /// this crate requires a config file, and entries built this way feed the - /// same loaders ([`load_configured_amms_from_entries`], - /// [`load_configured_amms_lazy_from_entries`]). - /// - /// ``` - /// use alloy_primitives::Address; - /// use evm_amm_state::configured_amms::{AmmConfigEntry, AmmType}; - /// - /// let entry = AmmConfigEntry::new(AmmType::UniswapV3, Address::ZERO) - /// .with_tokens(vec![Address::ZERO, Address::repeat_byte(1)]) - /// .with_fee_tier(3000); - /// ``` - pub fn new(kind: AmmType, address: Address) -> Self { - Self { - kind, - address, - tokens: Vec::new(), - fee_tier: None, - vault_address: None, - pool_id: None, - tick_spacing: None, - stable: None, - factory_address: None, - hooks: None, - curve_use_uint256: None, - balancer_v3_pool_type: None, - } - } - - /// Set the pool's tokens (used for routing/relevance filtering). - pub fn with_tokens(mut self, tokens: Vec
) -> Self { - self.tokens = tokens; - self - } - - /// Set the fee tier (Uniswap/Pancake V3, in hundredths of a bip). - pub fn with_fee_tier(mut self, fee_tier: u32) -> Self { - self.fee_tier = Some(fee_tier); - self - } - - /// Set the Balancer V2 vault address backing this pool. - pub fn with_vault_address(mut self, vault_address: Address) -> Self { - self.vault_address = Some(vault_address); - self - } - - /// Set the Balancer V2 pool id. - pub fn with_pool_id(mut self, pool_id: B256) -> Self { - self.pool_id = Some(pool_id); - self - } - - /// Set the tick spacing (Slipstream / Uniswap V4). - pub fn with_tick_spacing(mut self, tick_spacing: i32) -> Self { - self.tick_spacing = Some(tick_spacing); - self - } - - /// Set whether a Solidly V2 pool is stable (`true`) or volatile (`false`). - pub fn with_stable(mut self, stable: bool) -> Self { - self.stable = Some(stable); - self - } - - /// Set the factory address (Solidly V2 / Slipstream). - pub fn with_factory_address(mut self, factory_address: Address) -> Self { - self.factory_address = Some(factory_address); - self - } - - /// Set the hooks address (Uniswap V4). - pub fn with_hooks(mut self, hooks: Address) -> Self { - self.hooks = Some(hooks); - self - } - - /// Select Curve index encoding: `false` = stableswap (int128 indices), - /// `true` = cryptoswap (uint256 indices). - pub fn with_curve_use_uint256(mut self, curve_use_uint256: bool) -> Self { - self.curve_use_uint256 = Some(curve_use_uint256); - self - } - - /// Hint the Balancer V3 pool type to skip auto-detection. - pub fn with_balancer_v3_pool_type(mut self, pool_type: BalancerV3PoolType) -> Self { - self.balancer_v3_pool_type = Some(pool_type); - self - } -} - -#[cfg(feature = "toml")] -#[derive(Debug, Deserialize)] -struct AmmConfigFile { - amms: HashMap>, -} - -/// Filters AMM config entries to those involving at least one token of interest. -/// -/// An AMM is included if ANY of its tokens appear in `relevant_tokens`, or if it -/// carries no token metadata (conservative — kept to avoid dropping usable -/// pools). Pass an empty set to keep everything. -/// -/// Returns the filtered entries and the count of filtered-out entries. -pub fn filter_amm_entries_by_tokens( - entries: &[AmmConfigEntry], - relevant_tokens: &HashSet
, -) -> (Vec, usize) { - if relevant_tokens.is_empty() { - // No tokens specified — keep all AMMs (conservative). - return (entries.to_vec(), 0); - } - - let mut included = Vec::with_capacity(entries.len()); - let mut filtered_count = 0; - - for entry in entries { - let has_relevant_token = entry.tokens.iter().any(|t| relevant_tokens.contains(t)); - - if has_relevant_token || entry.tokens.is_empty() { - included.push(entry.clone()); - } else { - filtered_count += 1; - debug!( - amm = %entry.address, - amm_tokens = ?entry.tokens, - "filtered out AMM (no overlap with tokens of interest)" - ); - } - } - - (included, filtered_count) -} - -/// Filters AMM config entries to pools that can form a routing leg between -/// tokens of interest. -/// -/// A pool is included if it contains at least 2 tokens from `routing_tokens` -/// (so it can be an intermediate hop), or if it carries no token metadata. For -/// routing A→B via WETH, pass `{A, B, WETH}`. Unlike -/// [`filter_amm_entries_by_tokens`], a single-token overlap is not enough. -/// -/// Returns the filtered entries and the count of filtered-out entries. -pub fn filter_amm_entries_for_swap( - entries: &[AmmConfigEntry], - routing_tokens: &HashSet
, -) -> (Vec, usize) { - if routing_tokens.len() < 2 { - // Need at least 2 tokens for a routing-leg filter to make sense. - return filter_amm_entries_by_tokens(entries, routing_tokens); - } - - let mut included = Vec::with_capacity(entries.len()); - let mut filtered_count = 0; - - for entry in entries { - let overlap_count = entry - .tokens - .iter() - .filter(|t| routing_tokens.contains(*t)) - .count(); - - if overlap_count >= 2 || entry.tokens.is_empty() { - // Pool has at least 2 routing-relevant tokens (valid leg), or has no - // token info (conservative — include to avoid missing pools). - included.push(entry.clone()); - } else { - filtered_count += 1; - debug!( - amm = %entry.address, - amm_tokens = ?entry.tokens, - overlap = overlap_count, - "filtered out AMM (insufficient token overlap for routing)" - ); - } - } - - (included, filtered_count) -} - -/// Loads AMM config entries from a TOML file for a specific chain. -/// -/// This function just parses the config without initializing the AMMs, -/// allowing for filtering before the expensive RPC calls. -/// -/// TOML support is optional: this function is only available with the `toml` -/// feature (enabled by default). Without it, build [`AmmConfigEntry`] values -/// directly and call the `*_from_entries` loaders. -#[cfg(feature = "toml")] -pub fn load_amm_config_entries( - chain_name: &str, - file_path: Option<&Path>, -) -> anyhow::Result> { - let Some(path) = file_path else { - return Ok(Vec::new()); - }; - - let content = std::fs::read_to_string(path)?; - let parsed: AmmConfigFile = toml::from_str(&content)?; - - Ok(parsed.amms.get(chain_name).cloned().unwrap_or_default()) -} - -/// Load and fully initialize every AMM configured for `chain_name` from a TOML -/// file. -/// -/// Reads entries from the TOML file at `file_path` (returns an empty map when -/// `file_path` is `None`), then initializes each pool eagerly — including the -/// expensive V3 tick scan — via [`load_configured_amms_from_entries`]. -/// `default_balancer_vault` is used for Balancer entries that omit an explicit -/// `vault_address`. Entries that fail to load are recorded as `None`. -/// -/// Only available with the `toml` feature. For programmatic config, build -/// [`AmmConfigEntry`] values and call [`load_configured_amms_from_entries`]. -#[cfg(feature = "toml")] -pub async fn load_configured_amms( - cache: &mut EvmCache, - chain_name: &str, - default_balancer_vault: Address, - file_path: Option<&Path>, -) -> anyhow::Result>> { - let entries = load_amm_config_entries(chain_name, file_path)?; - load_configured_amms_from_entries(cache, &entries, default_balancer_vault).await -} - -/// Load and fully initialize AMMs from in-memory entries (no TOML required). -/// -/// This is the primary entry point for callers that build their AMM set -/// programmatically (or load it from any source). Fully initializes all pools, -/// including V3 tick data. `default_balancer_vault` is used for Balancer -/// entries that omit an explicit `vault_address`. Entries that fail to load are -/// recorded as `None`. -pub async fn load_configured_amms_from_entries( - cache: &mut EvmCache, - entries: &[AmmConfigEntry], - default_balancer_vault: Address, -) -> anyhow::Result>> { - let (mut amms, deferred) = - init_amms_phase1_phase2(cache, entries, default_balancer_vault).await?; - complete_deferred_v3_work(cache, deferred, &mut amms).await?; - Ok(amms) -} - -/// Load AMMs from a TOML file with deferred V3 tick initialization. -/// -/// V2 and Balancer pools are fully initialized. V3 pools that have -/// preloaded storage (cache hit) are also fully initialized. V3 pools -/// needing resync are returned in `DeferredV3Work` — call -/// `complete_deferred_v3_work()` to finish them before simulation. -/// -/// Only available with the `toml` feature. For programmatic config, use -/// [`load_configured_amms_lazy_from_entries`]. -#[cfg(feature = "toml")] -pub async fn load_configured_amms_lazy( - cache: &mut EvmCache, - chain_name: &str, - default_balancer_vault: Address, - file_path: Option<&Path>, -) -> anyhow::Result<(HashMap>, DeferredV3Work)> { - let entries = load_amm_config_entries(chain_name, file_path)?; - init_amms_phase1_phase2(cache, &entries, default_balancer_vault).await -} - -/// Load AMMs from in-memory entries with deferred V3 tick initialization (no -/// TOML required). -/// -/// The programmatic counterpart to [`load_configured_amms_lazy`]: V2/Balancer -/// pools and V3 metadata are initialized now, and the expensive V3 tick -/// prefetch is deferred into the returned [`DeferredV3Work`]. Finish it with -/// [`complete_deferred_v3_work`] before simulating. -pub async fn load_configured_amms_lazy_from_entries( - cache: &mut EvmCache, - entries: &[AmmConfigEntry], - default_balancer_vault: Address, -) -> anyhow::Result<(HashMap>, DeferredV3Work)> { - init_amms_phase1_phase2(cache, entries, default_balancer_vault).await -} - -/// Phase 1+2: Prefetch accounts and initialize AMMs (V2/Balancer fully, V3 metadata only). -/// -/// V3 pools needing tick resync are returned in `DeferredV3Work` for later completion -/// via `complete_deferred_v3_work()`. -async fn init_amms_phase1_phase2( - cache: &mut EvmCache, - entries: &[AmmConfigEntry], - default_balancer_vault: Address, -) -> anyhow::Result<(HashMap>, DeferredV3Work)> { - if entries.is_empty() { - return Ok(( - HashMap::new(), - DeferredV3Work { - needs_resync: Vec::new(), - needs_incremental: Vec::new(), - }, - )); - } - - let total_start = Instant::now(); - - info!(count = entries.len(), "loading configured AMMs"); - - // Phase 1: Prefetch all accounts in parallel - let balancer_entries: Vec<(B256, Address)> = entries - .iter() - .filter_map(|e| match e.kind { - AmmType::Balancer => e - .pool_id - .map(|pid| (pid, e.vault_address.unwrap_or(default_balancer_vault))), - _ => None, - }) - .collect(); - - let prefetch_start = Instant::now(); - - let mut all_addresses: Vec
= Vec::new(); - let mut seen: HashSet
= HashSet::new(); - for entry in entries.iter() { - match entry.kind { - AmmType::UniswapV2 - | AmmType::UniswapV3 - | AmmType::PancakeSwapV3 - | AmmType::BalancerV3 - | AmmType::Curve - | AmmType::SolidlyV2 - | AmmType::Slipstream - | AmmType::UniswapV4 => { - if seen.insert(entry.address) { - all_addresses.push(entry.address); - } - } - AmmType::Balancer => {} // handled via balancer_entries below - } - } - for (pool_id, vault) in &balancer_entries { - let pool_addr = BalancerPool::address(*pool_id); - if seen.insert(pool_addr) { - all_addresses.push(pool_addr); - } - if seen.insert(*vault) { - all_addresses.push(*vault); - } - } - - debug!( - total_addresses = all_addresses.len(), - "prefetching all pool accounts" - ); - prefetch_accounts(cache, &all_addresses).await?; - - let total_prefetch_ms = prefetch_start.elapsed().as_millis(); - debug!(total_prefetch_ms, "prefetch phase complete"); - - // Purge Balancer vault storage once before the init loop. - // The vault is a shared contract — purging per-pool would wipe data - // that was just fetched by the previous pool's init. - { - let mut purged_vaults: HashSet
= HashSet::new(); - for (_, vault) in &balancer_entries { - if purged_vaults.insert(*vault) { - cache.purge_pool_storage(*vault); - } - } - } - - // Phase 2: Initialize AMMs — V2/Balancer fully, V3 phase1 only (determines cache status) - let init_start = Instant::now(); - let init_pb = progress_bar(entries.len() as u64, "Initializing AMMs"); - let mut amms = HashMap::new(); - let mut v3_needs_resync: Vec = Vec::new(); - let mut v3_needs_incremental: Vec = Vec::new(); - - for entry in entries.iter() { - let address = entry.address; - init_pb.set_message(format!("{:.8}...", address)); - - match entry.kind { - AmmType::UniswapV2 => { - let fee: usize = entry.fee_tier.unwrap_or(300).try_into().unwrap(); - match init_uniswap_v2_from_cache(cache, address, fee).await { - Ok(pool) => { - amms.insert(address, Some(LocalAMM::UniswapV2(pool))); - } - Err(e) => { - warn!("Failed to load V2 AMM {:?}: {:?}", address, e); - amms.insert(address, None); - } - } - } - AmmType::UniswapV3 | AmmType::PancakeSwapV3 => { - let is_pancake = matches!(entry.kind, AmmType::PancakeSwapV3); - let flavor = if is_pancake { - V3Flavor::PancakeSwapV3 - } else { - V3Flavor::UniswapV3 - }; - let wrapper: WrapperFn = if is_pancake { - Box::new(LocalAMM::PancakeSwapV3) - } else { - Box::new(LocalAMM::UniswapV3) - }; - - match init_v3_phase1(cache, address, flavor).await { - Ok(V3InitPhase1Result::Complete(pool)) => { - amms.insert(address, Some(wrapper(pool))); - } - Ok(V3InitPhase1Result::NeedsResync { pool, flavor }) => { - v3_needs_resync.push((address, pool, flavor, wrapper)); - } - Ok(V3InitPhase1Result::NeedsIncrementalResync { - pool, - flavor, - old_bitmap, - old_ticks, - }) => { - v3_needs_incremental - .push((address, pool, flavor, old_bitmap, old_ticks, wrapper)); - } - Err(e) => { - warn!("Failed to load V3 AMM {:?}: {:?}", address, e); - amms.insert(address, None); - } - } - } - AmmType::Balancer => { - let pool_id = entry.pool_id.ok_or_else(|| { - anyhow::anyhow!("Missing pool_id for Balancer AMM {address:?}") - })?; - let vault_address = entry.vault_address.unwrap_or(default_balancer_vault); - - match init_balancer_from_cache(cache, pool_id, vault_address).await { - Ok(pool) => { - amms.insert(address, Some(LocalAMM::Balancer(pool))); - } - Err(e) => { - warn!("Failed to load Balancer AMM {:?}: {:?}", address, e); - amms.insert(address, None); - } - } - } - AmmType::BalancerV3 => { - let tokens = &entry.tokens; - let vault = entry.vault_address.unwrap_or(Address::ZERO); - let token_a = tokens.first().copied().unwrap_or(Address::ZERO); - let token_b = tokens.get(1).copied().unwrap_or(Address::ZERO); - match crate::cache_sync::init_balancer_v3_from_cache( - cache, - address, - vault, - token_a, - token_b, - entry.balancer_v3_pool_type, - ) - .await - { - Ok(pool) => { - amms.insert(address, Some(LocalAMM::BalancerV3(pool))); - } - Err(e) => { - warn!("Failed to load BalancerV3 AMM {:?}: {:?}", address, e); - amms.insert(address, None); - } - } - } - AmmType::Curve => { - let use_uint256 = entry.curve_use_uint256.unwrap_or(false); - match crate::cache_sync::init_curve_from_cache( - cache, - address, - &entry.tokens, - use_uint256, - ) - .await - { - Ok(pool) => { - amms.insert(address, Some(LocalAMM::Curve(pool))); - } - Err(e) => { - warn!("Failed to load Curve AMM {:?}: {:?}", address, e); - amms.insert(address, None); - } - } - } - AmmType::SolidlyV2 => { - match crate::cache_sync::init_solidly_v2_from_cache( - cache, - address, - entry.stable.unwrap_or(false), - entry.factory_address.unwrap_or(Address::ZERO), - entry.fee_tier.unwrap_or(30), - ) - .await - { - Ok(pool) => { - amms.insert(address, Some(LocalAMM::SolidlyV2(pool))); - } - Err(e) => { - warn!("Failed to load SolidlyV2 AMM {:?}: {:?}", address, e); - amms.insert(address, None); - } - } - } - AmmType::Slipstream => { - match crate::cache_sync::init_slipstream_from_cache( - cache, - address, - entry.tick_spacing.unwrap_or(1), - ) - .await - { - Ok(pool) => { - // Slipstream pools need tick data — defer to phase 3 - // if they have no bitmap data yet - if pool.tick_bitmap.is_empty() { - let ts = entry.tick_spacing.unwrap_or(1); - v3_needs_resync.push(( - address, - pool.as_v3_pool(), - V3Flavor::Slipstream, - Box::new(move |p| { - LocalAMM::Slipstream(SlipstreamPool::from_v3_pool(p, ts)) - }), - )); - } else { - amms.insert(address, Some(LocalAMM::Slipstream(pool))); - } - } - Err(e) => { - warn!("Failed to load Slipstream AMM {:?}: {:?}", address, e); - amms.insert(address, None); - } - } - } - AmmType::UniswapV4 => { - use crate::uniswap_v4_pool::UniswapV4Pool; - let tokens = &entry.tokens; - let pool = UniswapV4Pool { - address, - currency0: tokens.first().copied().unwrap_or(Address::ZERO), - currency1: tokens.get(1).copied().unwrap_or(Address::ZERO), - fee: entry.fee_tier.unwrap_or(3000), - tick_spacing: entry.tick_spacing.unwrap_or(60), - hooks: entry.hooks.unwrap_or(Address::ZERO), - tick: 0, - sqrt_price: U256::ZERO, - liquidity: 0, - }; - amms.insert(address, Some(LocalAMM::UniswapV4(pool))); - } - }; - - init_pb.inc(1); - } - - finish_with_message( - &init_pb, - &format!( - "{} loaded, {} deferred resync, {} deferred incremental, {} failed", - amms.values().filter(|opt| opt.is_some()).count(), - v3_needs_resync.len(), - v3_needs_incremental.len(), - amms.values().filter(|opt| opt.is_none()).count(), - ), - ); - - let total_ms = total_start.elapsed().as_millis(); - let loaded_count = amms.values().filter(|opt| opt.is_some()).count(); - debug!( - loaded = loaded_count, - failed = amms.len() - loaded_count, - deferred_resync = v3_needs_resync.len(), - deferred_incremental = v3_needs_incremental.len(), - init_ms = init_start.elapsed().as_millis(), - total_prefetch_ms = total_prefetch_ms, - total_ms = total_ms, - "AMM init phase 1+2 complete" - ); - - let deferred = DeferredV3Work { - needs_resync: v3_needs_resync, - needs_incremental: v3_needs_incremental, - }; - - Ok((amms, deferred)) -} - -/// Complete deferred V3 tick initialization. -/// -/// This runs the expensive bitmap prefetch and tick resync for V3 pools -/// that were deferred during `load_configured_amms_lazy()`. -pub async fn complete_deferred_v3_work( - cache: &mut EvmCache, - deferred: DeferredV3Work, - amms: &mut HashMap>, -) -> anyhow::Result<()> { - // Phase 3a: Parallel bitmap prefetch + resync for V3 pools with no snapshot (cold start) - let mut v3_resync_ms = 0u128; - if !deferred.needs_resync.is_empty() { - let bitmap_start = Instant::now(); - - let targets: Vec = deferred - .needs_resync - .iter() - .map(|(addr, pool, flavor, _)| { - let scan_params = compute_adaptive_scan_params(pool.liquidity, pool.tick_spacing); - V3BitmapPrefetchTarget { - address: *addr, - flavor: *flavor, - tick_spacing: pool.tick_spacing, - center_tick: pool.tick, - max_scan_words: scan_params.max_scan_words, - empty_word_threshold: scan_params.empty_word_threshold, - } - }) - .collect(); - - prefetch_v3_bitmap_slots(cache, &targets).await?; - let bitmap_ms = bitmap_start.elapsed().as_millis(); - debug!( - bitmap_prefetch_ms = bitmap_ms, - "bitmap prefetch phase complete (deferred)" - ); - - // Prefetch tick info slots (reads cached bitmaps, then parallel-fetches tick data) - let tick_info_start = Instant::now(); - let prefetch_stats = prefetch_v3_tick_info_slots(cache, &targets).await?; - let tick_info_ms = tick_info_start.elapsed().as_millis(); - debug!( - tick_info_prefetch_ms = tick_info_ms, - "tick info prefetch phase complete (deferred)" - ); - - // Complete V3 resync (bitmap + tick info reads now hit cache — very fast) - let resync_start = Instant::now(); - let resync_pb = progress_bar( - deferred.needs_resync.len() as u64, - "Completing deferred V3 tick resync", - ); - - for (address, mut pool, flavor, wrapper) in deferred.needs_resync { - resync_pb.set_message(format!("{:.8}...", address)); - let pool_start = Instant::now(); - - if let Some(reason) = v3_prefetch_skip_reason(&prefetch_stats, address) { - let errors = prefetch_stats - .errors_by_pool - .get(&address) - .copied() - .unwrap_or(0); - let total_requested = prefetch_stats - .total_requested_by_pool - .get(&address) - .copied() - .unwrap_or(0); - warn!( - pool = %address, - failed_slots = errors, - total_slots = total_requested, - failure_pct = format!( - "{:.0}%", - errors as f64 / total_requested.max(1) as f64 * 100.0 - ), - reason, - "Skipping pool during deferred V3 resync" - ); - amms.insert(address, None); - resync_pb.inc(1); - continue; - } - - match sync_uniswap_v3_ticks(cache, &mut pool, flavor) { - Ok(()) => { - save_v3_tick_snapshot(cache, &pool); - let elapsed = pool_start.elapsed(); - debug!( - pool = %address, - elapsed_ms = elapsed.as_millis(), - "V3 pool resync complete (deferred)" - ); - amms.insert(address, Some(wrapper(pool))); - } - Err(e) => { - warn!("Failed to resync V3 ticks for {:?}: {:?}", address, e); - amms.insert(address, None); - } - } - resync_pb.inc(1); - } - - v3_resync_ms = resync_start.elapsed().as_millis(); - finish_with_message(&resync_pb, "Deferred V3 resync complete"); - } - - // Phase 3b: Parallel prefetch + incremental resync for V3 pools with stale snapshots - let mut v3_incremental_ms = 0u128; - if !deferred.needs_incremental.is_empty() { - let incr_start = Instant::now(); - - // Build prefetch targets: (address, flavor, tick_spacing, current_tick, liquidity, &old_bitmap) - let prefetch_targets: Vec<_> = deferred - .needs_incremental - .iter() - .map(|(addr, pool, flavor, old_bitmap, _, _)| { - ( - *addr, - *flavor, - pool.tick_spacing, - pool.tick, - pool.liquidity, - old_bitmap as &std::collections::HashMap, - ) - }) - .collect(); - - let prefetch_stats = prefetch_v3_incremental_resync_slots(cache, &prefetch_targets).await?; - let prefetch_ms = incr_start.elapsed().as_millis(); - debug!( - incremental_prefetch_ms = prefetch_ms, - pools = deferred.needs_incremental.len(), - pools_with_errors = prefetch_stats.errors_by_pool.len(), - "incremental resync prefetch phase complete (deferred)" - ); - - // Run incremental resync with pre_purged=true (all reads hit cache) - let resync_start = Instant::now(); - let resync_pb = progress_bar( - deferred.needs_incremental.len() as u64, - "Completing deferred V3 incremental resync", - ); - - for (address, mut pool, flavor, old_bitmap, old_ticks, wrapper) in - deferred.needs_incremental - { - if let Some(reason) = v3_prefetch_skip_reason(&prefetch_stats, address) { - let errors = prefetch_stats - .errors_by_pool - .get(&address) - .copied() - .unwrap_or(0); - let total_requested = prefetch_stats - .total_requested_by_pool - .get(&address) - .copied() - .unwrap_or(0); - warn!( - pool = %address, - failed_slots = errors, - total_slots = total_requested, - failure_pct = format!( - "{:.0}%", - errors as f64 / total_requested.max(1) as f64 * 100.0 - ), - reason, - "Skipping pool during deferred V3 incremental resync" - ); - amms.insert(address, None); - resync_pb.inc(1); - continue; - } - - resync_pb.set_message(format!("{:.8}...", address)); - let pool_start = Instant::now(); - - match incremental_sync_v3_ticks(cache, &mut pool, &old_bitmap, &old_ticks, flavor, true) - { - Ok(()) => { - inject_v3_tick_data(cache, address, &pool, flavor); - let elapsed = pool_start.elapsed(); - debug!( - pool = %address, - elapsed_ms = elapsed.as_millis(), - "V3 pool incremental resync complete (deferred)" - ); - amms.insert(address, Some(wrapper(pool))); - } - Err(e) => { - warn!("Failed incremental resync for V3 {:?}: {:?}", address, e); - amms.insert(address, None); - } - } - resync_pb.inc(1); - } - - v3_incremental_ms = resync_start.elapsed().as_millis(); - finish_with_message(&resync_pb, "Deferred V3 incremental resync complete"); - } - - debug!( - v3_resync_ms, - v3_incremental_ms, "complete_deferred_v3_work done" - ); - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn make_entry(addr_byte: u8, token_bytes: &[u8]) -> AmmConfigEntry { - AmmConfigEntry { - kind: AmmType::UniswapV2, - address: Address::repeat_byte(addr_byte), - tokens: token_bytes - .iter() - .map(|b| Address::repeat_byte(*b)) - .collect(), - fee_tier: None, - vault_address: None, - pool_id: None, - tick_spacing: None, - stable: None, - factory_address: None, - hooks: None, - curve_use_uint256: None, - balancer_v3_pool_type: None, - } - } - - #[test] - fn test_filter_empty_active_tokens_includes_all() { - let entries = vec![ - make_entry(0x01, &[0xAA, 0xBB]), - make_entry(0x02, &[0xCC, 0xDD]), - ]; - let active = HashSet::new(); - - let (filtered, count) = filter_amm_entries_by_tokens(&entries, &active); - - assert_eq!(filtered.len(), 2); - assert_eq!(count, 0); - } - - #[test] - fn test_filter_with_overlap_includes_matching() { - let entries = vec![ - make_entry(0x01, &[0xAA, 0xBB]), // Has 0xAA - make_entry(0x02, &[0xCC, 0xDD]), // No overlap - make_entry(0x03, &[0xAA, 0xCC]), // Has 0xAA - ]; - let active: HashSet
= [Address::repeat_byte(0xAA)].into_iter().collect(); - - let (filtered, count) = filter_amm_entries_by_tokens(&entries, &active); - - assert_eq!(filtered.len(), 2); - assert_eq!(count, 1); - assert!( - filtered - .iter() - .any(|e| e.address == Address::repeat_byte(0x01)) - ); - assert!( - filtered - .iter() - .any(|e| e.address == Address::repeat_byte(0x03)) - ); - } - - #[test] - fn test_filter_no_overlap_excludes_all() { - let entries = vec![ - make_entry(0x01, &[0xAA, 0xBB]), - make_entry(0x02, &[0xCC, 0xDD]), - ]; - let active: HashSet
= [Address::repeat_byte(0xFF)].into_iter().collect(); - - let (filtered, count) = filter_amm_entries_by_tokens(&entries, &active); - - assert_eq!(filtered.len(), 0); - assert_eq!(count, 2); - } - - #[test] - fn test_filter_empty_token_list_always_included() { - // AMMs without token info should always be included (conservative) - let entries = vec![ - make_entry(0x01, &[]), // Empty tokens - make_entry(0x02, &[0xAA, 0xBB]), // Has tokens but no overlap - ]; - let active: HashSet
= [Address::repeat_byte(0xFF)].into_iter().collect(); - - let (filtered, count) = filter_amm_entries_by_tokens(&entries, &active); - - assert_eq!(filtered.len(), 1); // Only the empty-tokens entry - assert_eq!(count, 1); - assert_eq!(filtered[0].address, Address::repeat_byte(0x01)); - } - - #[test] - fn test_filter_multiple_active_tokens() { - let entries = vec![ - make_entry(0x01, &[0xAA, 0xBB]), - make_entry(0x02, &[0xCC, 0xDD]), - make_entry(0x03, &[0xEE, 0xFF]), - ]; - let active: HashSet
= [Address::repeat_byte(0xAA), Address::repeat_byte(0xDD)] - .into_iter() - .collect(); - - let (filtered, count) = filter_amm_entries_by_tokens(&entries, &active); - - assert_eq!(filtered.len(), 2); // Entry 1 (has AA) and entry 2 (has DD) - assert_eq!(count, 1); // Entry 3 filtered out - } - - #[test] - fn test_filter_partial_overlap_includes() { - // If an AMM has tokens [A, B] and only A is active, include it - let entries = vec![make_entry(0x01, &[0xAA, 0xBB])]; - let active: HashSet
= [Address::repeat_byte(0xAA)].into_iter().collect(); - - let (filtered, count) = filter_amm_entries_by_tokens(&entries, &active); - - assert_eq!(filtered.len(), 1); - assert_eq!(count, 0); - } - - #[cfg(feature = "toml")] - #[test] - fn test_pancake_swap_v3_toml_deserialization() { - let toml_content = r#" -[[amms.arbitrum]] -type = "pancake_swap_v3" -address = "0x7fCDc35463E3770c2fB992716Cd070B63540b947" -tokens = [ - "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", - "0x82af49447d8a07e3bd95bd0d56f35241523fbab1", -] -fee_tier = 100 -"#; - let parsed: AmmConfigFile = toml::from_str(toml_content).expect("should parse TOML"); - let entries = parsed.amms.get("arbitrum").expect("should have arbitrum"); - assert_eq!(entries.len(), 1); - assert!(matches!(entries[0].kind, AmmType::PancakeSwapV3)); - assert_eq!(entries[0].fee_tier, Some(100)); - } - - #[cfg(feature = "toml")] - #[test] - fn test_balancer_toml_deserialization_with_vault_override() { - let toml_content = r#" -[[amms.base]] -type = "balancer" -address = "0xa04259de0129ac4c4a0ce22be2ec729482034ba0" -vault_address = "0xBA12222222228d8Ba445958a75a0704d566BF2C8" -pool_id = "0xa04259de0129ac4c4a0ce22be2ec729482034ba000020000000000000000016d" -tokens = [ - "0x4158734D47Fc9692176B5085E0F52ee0Da5d47F1", - "0x1509706a6c66CA549ff0cB464de88231DDBe213B", -] -"#; - - let parsed: AmmConfigFile = toml::from_str(toml_content).expect("should parse TOML"); - let entries = parsed.amms.get("base").expect("should have base"); - assert_eq!(entries.len(), 1); - assert!(matches!(entries[0].kind, AmmType::Balancer)); - assert_eq!( - entries[0].vault_address, - Some( - Address::parse_checksummed("0xBA12222222228d8Ba445958a75a0704d566BF2C8", None) - .expect("valid address") - ) - ); - } - - #[test] - fn test_pancake_swap_v3_grouped_with_v3_in_prefetch() { - // PancakeSwapV3 entries should be grouped with UniswapV3 for prefetching - let entries = [ - AmmConfigEntry { - kind: AmmType::UniswapV3, - address: Address::repeat_byte(0x01), - tokens: vec![], - fee_tier: Some(3000), - vault_address: None, - pool_id: None, - tick_spacing: None, - stable: None, - factory_address: None, - hooks: None, - curve_use_uint256: None, - balancer_v3_pool_type: None, - }, - AmmConfigEntry { - kind: AmmType::PancakeSwapV3, - address: Address::repeat_byte(0x02), - tokens: vec![], - fee_tier: Some(100), - vault_address: None, - pool_id: None, - tick_spacing: None, - stable: None, - factory_address: None, - hooks: None, - curve_use_uint256: None, - balancer_v3_pool_type: None, - }, - ]; - - // Count V3-style entries the same way the prefetch phase does - let v3_count = entries - .iter() - .filter(|e| matches!(e.kind, AmmType::UniswapV3 | AmmType::PancakeSwapV3)) - .count(); - assert_eq!( - v3_count, 2, - "both UniswapV3 and PancakeSwapV3 should be V3-style" - ); - } - - #[test] - fn test_filter_pancake_swap_v3_by_tokens() { - let entries = vec![ - make_entry(0x01, &[0xAA, 0xBB]), - AmmConfigEntry { - kind: AmmType::PancakeSwapV3, - address: Address::repeat_byte(0x02), - tokens: vec![Address::repeat_byte(0xCC), Address::repeat_byte(0xDD)], - fee_tier: Some(100), - vault_address: None, - pool_id: None, - tick_spacing: None, - stable: None, - factory_address: None, - hooks: None, - curve_use_uint256: None, - balancer_v3_pool_type: None, - }, - ]; - - // Only 0xCC is active, so only PancakeSwapV3 entry should be included - let active: HashSet
= [Address::repeat_byte(0xCC)].into_iter().collect(); - let (filtered, count) = filter_amm_entries_by_tokens(&entries, &active); - - assert_eq!(filtered.len(), 1); - assert_eq!(count, 1); - assert!(matches!(filtered[0].kind, AmmType::PancakeSwapV3)); - } - - #[cfg(feature = "toml")] - #[test] - fn test_curve_toml_deserialization_with_use_uint256() { - let toml_content = r#" -[[amms.arbitrum]] -type = "curve" -address = "0x0000000000000000000000000000000000000001" -tokens = [ - "0x11cDb42B0EB46D95f990BeDD4695A6e3fA034978", - "0x2f2a2543B76A4166549F7aaB2e75Bef0aefC5B0f", - "0x82af49447d8a07e3bd95bd0d56f35241523fbab1", -] -curve_use_uint256 = true -"#; - let parsed: AmmConfigFile = toml::from_str(toml_content).expect("should parse TOML"); - let entries = parsed.amms.get("arbitrum").expect("should have arbitrum"); - assert_eq!(entries.len(), 1); - assert!(matches!(entries[0].kind, AmmType::Curve)); - assert_eq!(entries[0].tokens.len(), 3); - assert_eq!(entries[0].curve_use_uint256, Some(true)); - } - - #[cfg(feature = "toml")] - #[test] - fn test_curve_toml_deserialization_defaults() { - // Curve entries without explicit curve_use_uint256 should parse with None - let toml_content = r#" -[[amms.ethereum]] -type = "curve" -address = "0x0000000000000000000000000000000000000002" -tokens = [ - "0xD533a949740bb3306d119CC777fa900bA034cd52", - "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", -] -"#; - let parsed: AmmConfigFile = toml::from_str(toml_content).expect("should parse TOML"); - let entries = parsed.amms.get("ethereum").expect("should have ethereum"); - assert_eq!(entries.len(), 1); - assert!(matches!(entries[0].kind, AmmType::Curve)); - assert_eq!(entries[0].tokens.len(), 2); - assert!(entries[0].curve_use_uint256.is_none()); - } - - #[cfg(feature = "toml")] - #[test] - fn test_balancer_v3_pool_type_hint_deserialization() { - let toml_content = r#" -[[amms.arbitrum]] -type = "balancer_v3" -address = "0x5418a64e0cdb20548acb394f5d00a089baf02161" -vault_address = "0xbA1333333333a1BA1108E8412f11850A5C319bA9" -balancer_v3_pool_type = "stable" -tokens = [ - "0x4ce13a79f45c1be00bdabd38b764ac28c082704e", - "0xec70dcb4a1efa46b8f2d97c310c9c4790ba5ffa8", -] -"#; - let parsed: AmmConfigFile = toml::from_str(toml_content).expect("should parse TOML"); - let entries = parsed.amms.get("arbitrum").expect("should have arbitrum"); - assert_eq!(entries.len(), 1); - assert!(matches!(entries[0].kind, AmmType::BalancerV3)); - assert_eq!( - entries[0].balancer_v3_pool_type, - Some(BalancerV3PoolType::Stable) - ); - } - - #[cfg(feature = "toml")] - #[test] - fn test_balancer_v3_pool_type_hint_defaults_to_none() { - let toml_content = r#" -[[amms.arbitrum]] -type = "balancer_v3" -address = "0x5418a64e0cdb20548acb394f5d00a089baf02161" -vault_address = "0xbA1333333333a1BA1108E8412f11850A5C319bA9" -tokens = [ - "0x4ce13a79f45c1be00bdabd38b764ac28c082704e", - "0xec70dcb4a1efa46b8f2d97c310c9c4790ba5ffa8", -] -"#; - let parsed: AmmConfigFile = toml::from_str(toml_content).expect("should parse TOML"); - let entries = parsed.amms.get("arbitrum").expect("should have arbitrum"); - assert!(entries[0].balancer_v3_pool_type.is_none()); - } - - #[test] - fn test_v3_prefetch_skip_reason_matches_non_archive_errors() { - let address = Address::repeat_byte(0x42); - let stats = V3PrefetchStats { - errors_by_pool: HashMap::from([(address, 1)]), - total_requested_by_pool: HashMap::from([(address, 600)]), - error_samples_by_pool: HashMap::from([( - address, - "missing trie node: state is not available, not found".to_string(), - )]), - }; - - assert_eq!( - v3_prefetch_skip_reason(&stats, address), - Some("required historical storage is unavailable on the current RPC") - ); - } - - #[test] - fn test_v3_prefetch_skip_reason_matches_large_failure_ratio() { - let address = Address::repeat_byte(0x43); - let stats = V3PrefetchStats { - errors_by_pool: HashMap::from([(address, 500)]), - total_requested_by_pool: HashMap::from([(address, 600)]), - error_samples_by_pool: HashMap::new(), - }; - - assert_eq!( - v3_prefetch_skip_reason(&stats, address), - Some("too many tick slots failed prefetch and would trigger serial RPC fallback") - ); - } - - #[test] - fn test_v3_prefetch_skip_reason_ignores_small_transient_failures() { - let address = Address::repeat_byte(0x44); - let stats = V3PrefetchStats { - errors_by_pool: HashMap::from([(address, 4)]), - total_requested_by_pool: HashMap::from([(address, 600)]), - error_samples_by_pool: HashMap::from([( - address, - "HTTP error 429: rate limited".to_string(), - )]), - }; - - assert_eq!(v3_prefetch_skip_reason(&stats, address), None); - } -} diff --git a/src/cryptoswap_math.rs b/src/cryptoswap_math.rs deleted file mode 100644 index ed323a4..0000000 --- a/src/cryptoswap_math.rs +++ /dev/null @@ -1,532 +0,0 @@ -//! Curve CryptoSwap (tricrypto/twocrypto) simulation math. -//! -//! Implements the cryptoswap `get_dy` calculation for 2-coin pools. -//! Uses Newton's method to solve the modified invariant that blends -//! constant-product with constant-sum behavior via the gamma parameter. -//! -//! Reference: Curve's `CurveTwocryptoOptimized` and `CurveCryptoMathOptimized2`. - -use alloy_primitives::U256; - -const N_COINS_MAX: usize = 3; -const FEE_DENOMINATOR: u64 = 10_000_000_000; // 1e10 - -fn e18() -> U256 { - U256::from(10u64).pow(U256::from(18)) -} - -/// Safe division returning zero on zero denominator. -fn sdiv(a: U256, b: U256) -> U256 { - if b.is_zero() { U256::ZERO } else { a / b } -} - -/// Compute the CryptoSwap invariant D for a 2-coin pool. -/// -/// Closely follows Curve's Vyper `newton_D` from `CurveCryptoMathOptimized2`. -/// -/// The invariant equation is: -/// K * D * (sum_x) + prod_x = K * D^N + (D/N)^N -/// where K depends on A, gamma, and the balance ratios (K0). -pub fn newton_d(xp: &[U256; 2], a_gamma: [U256; 2]) -> U256 { - let one = e18(); - let a = a_gamma[0]; - let gamma = a_gamma[1]; - - if xp[0].is_zero() || xp[1].is_zero() { - return U256::ZERO; - } - - let s = xp[0] + xp[1]; - let mut d = s; - - // Use the mean as a better initial guess - // D = N * geometric_mean(x_i) is a good starting point - // For 2 coins: D ≈ 2 * sqrt(x0 * x1) — approximate via Newton - // Actually just start with S, the Vyper code does the same. - - let n = U256::from(2); - let a_mul = U256::from(10_000u64); // A_MULTIPLIER - - for _ in 0..256u32 { - let d_prev = d; - if d.is_zero() { - return s; - } - - // K0 = N^N * prod(x_i) * 1e18 / D^N - // For N=2: K0 = 4 * x0 * x1 * 1e18 / D^2 - // To avoid overflow, compute step by step: - // K0 = (4 * x0 * 1e18 / D) * x1 / D - let k0 = sdiv(U256::from(4) * xp[0] * one, d); - let k0 = sdiv(k0 * xp[1], d); - if k0.is_zero() { - return d; - } - - // _g1k0 = |gamma + 1e18 - K0| + 1 - let gpo = gamma + one; - let g1k0 = if gpo > k0 { - gpo - k0 + U256::from(1) - } else { - k0 - gpo + U256::from(1) - }; - - // mul1 = 1e18 * D / gamma * g1k0 / gamma * g1k0 * A_MUL / A - let mul1 = sdiv(one * d, gamma); - let mul1 = sdiv(mul1 * g1k0, gamma); - let mul1 = sdiv(mul1 * g1k0 * a_mul, a); - - // mul2 = (2e18 * N_COINS * K0) / g1k0 - let mul2 = sdiv(U256::from(2) * one * n * k0, g1k0); - - // neg_fprime = S + S*mul2/1e18 + mul1*N/K0 - mul2*D/1e18 - let term_a = s + sdiv(s * mul2, one); - let term_b = sdiv(mul1 * n, k0); - let term_c = sdiv(mul2 * d, one); - - if term_a + term_b < term_c + U256::from(1) { - return d; - } - let neg_fprime = term_a + term_b - term_c; - if neg_fprime.is_zero() { - return d; - } - - // D = D * (neg_fprime + S) / neg_fprime - D*D/neg_fprime - // with adjustment for K0 < 1e18 - let d_plus = sdiv(d * (neg_fprime + s), neg_fprime); - let mut d_minus = sdiv(d * d, neg_fprime); - if one > k0 { - d_minus += sdiv(sdiv(d * mul1, neg_fprime) * (one - k0), k0); - } - - d = if d_plus > d_minus { - d_plus - d_minus - } else { - (d_prev + U256::from(1)) / U256::from(2) - }; - - // Convergence check - if d > d_prev { - if d - d_prev <= U256::from(1) { - return d; - } - } else if d_prev - d <= U256::from(1) { - return d; - } - } - d -} - -/// Compute new balance `y[j]` given `D` and the other balance `xp[1-j]` already updated. -/// -/// Closely follows Curve's Vyper `newton_y` from `CurveCryptoSwap2`. -/// Uses a specialized Newton iteration to solve for y directly, avoiding the -/// numerical instability of `newton_d` for imbalanced inputs. -pub fn newton_y(a_gamma: [U256; 2], xp: &[U256; 2], d: U256, j: usize) -> U256 { - let x_j = xp[1 - j]; // the OTHER coin's balance - if x_j.is_zero() || d.is_zero() { - return U256::ZERO; - } - - let one = e18(); - let a = a_gamma[0]; - let gamma = a_gamma[1]; - let n = U256::from(2u64); - let n_sq = U256::from(4u64); // N_COINS^2 - let a_mul = U256::from(10_000u64); // A_MULTIPLIER - - // Initial guess: y = D^2 / (x_j * N^2) - let mut y = sdiv(d * d, x_j * n_sq); - if y.is_zero() { - y = d / n; - } - - // K0_i = 1e18 * N * x_j / D (partial K0 without y term) - let k0_i = sdiv(one * n * x_j, d); - - // Convergence limit - let conv = U256::from(100) - .max(x_j / U256::from(10u64).pow(U256::from(14))) - .max(d / U256::from(10u64).pow(U256::from(14))); - - let gpo = gamma + one; // gamma + 1e18 - - for _ in 0..255u32 { - let y_prev = y; - - // K0 = K0_i * y * N / D - let k0 = sdiv(k0_i * y * n, d); - if k0.is_zero() { - return y; - } - - // S = x_j + y - let s = x_j + y; - - // g1k0 = |gamma + 1e18 - K0| + 1 - let g1k0 = if gpo > k0 { - gpo - k0 + U256::from(1) - } else { - k0 - gpo + U256::from(1) - }; - - // mul1 = 1e18 * D / gamma * g1k0 / gamma * g1k0 * A_MULTIPLIER / ANN - let mul1 = sdiv(one * d, gamma); - let mul1 = sdiv(mul1 * g1k0, gamma); - let mul1 = sdiv(mul1 * g1k0 * a_mul, a); - - // mul2 = (1e18 + 2e18 * K0) / g1k0 - // Note: in newton_y the ENTIRE expression is divided by g1k0 - let mul2 = sdiv(one + U256::from(2) * one * k0, g1k0); - - // yfprime = 1e18 * y + S * mul2 + mul1 - let yfprime_full = one * y + s * mul2 + mul1; - // dyfprime = D * mul2 - let dyfprime = d * mul2; - - if yfprime_full < dyfprime { - y = y_prev / U256::from(2); - continue; - } - - // yfprime is modified in-place: yfprime -= dyfprime - let yfprime = yfprime_full - dyfprime; - - // fprime = yfprime / y (CRITICAL: divided by y, not just the difference!) - let fprime = sdiv(yfprime, y); - if fprime.is_zero() { - return y; - } - - // Newton step: - // y_minus = mul1 / fprime - // y_plus = (yfprime + 1e18*D) / fprime + y_minus * 1e18 / K0 - // y_minus += 1e18 * S / fprime - let y_minus_base = sdiv(mul1, fprime); - let y_plus = sdiv(yfprime + one * d, fprime) + sdiv(y_minus_base * one, k0); - let y_minus = y_minus_base + sdiv(one * s, fprime); - - if y_plus < y_minus { - y = y_prev / U256::from(2); - continue; - } - y = y_plus - y_minus; - - // Convergence check - let diff = if y > y_prev { y - y_prev } else { y_prev - y }; - if diff < conv.max(sdiv(y, U256::from(10u64).pow(U256::from(14)))) { - return y; - } - } - y -} - -/// Simulate a cryptoswap exchange for a 2-coin pool. -/// -/// - `reserves`: per-coin in native decimals -/// - `precision_multipliers`: per-coin `10^(18 - decimals[i])` -/// - `a`, `gamma`: pool parameters -/// - `price_scale[0]`: price of coin 1 relative to coin 0 (1e18) -/// - `fee_mid`, `fee_out`: fee range in parts-per-1e10 -/// - `i`, `j`: input/output indices -/// - `dx`: input amount in native decimals -#[allow(clippy::too_many_arguments)] -pub fn cryptoswap_get_dy( - reserves: &[U256], - precision_multipliers: &[U256], - a: U256, - gamma: U256, - price_scale: &[U256], - fee_mid: U256, - fee_out: U256, - i: usize, - j: usize, - dx: U256, -) -> Option { - let n = reserves.len(); - if !(2..=N_COINS_MAX).contains(&n) || i >= n || j >= n || i == j { - return None; - } - // This implementation only supports 2-coin cryptoswap pools. - // Tricrypto (3+ coins) requires a generalized newton_d/newton_y which is not yet implemented. - if n != 2 { - return None; - } - if dx.is_zero() { - return Some(U256::ZERO); - } - if price_scale.is_empty() { - return None; - } - - let one = e18(); - - // Convert A from stored convention (_A = A_ext * 100, stableswap A_PRECISION) - // to CryptoSwap convention (ANN = A_ext * A_MULTIPLIER = A_ext * 10000). - // Since stored a = A_ext * 100, ANN = a * 100. - let ann = a * U256::from(100); - - // Scale to internal 1e18 precision, normalized by price_scale - let scale = |k: usize, bal: U256| -> U256 { - let s = bal * precision_multipliers[k]; - if k == 0 { - s - } else { - s * price_scale[k - 1] / one - } - }; - - // Pre-swap scaled balances - let xp_before = [scale(0, reserves[0]), scale(1, reserves[1])]; - - // Compute D from pre-swap state - let d = newton_d(&xp_before, [ann, gamma]); - if d.is_zero() { - return None; - } - - // Post-swap xp: add dx to coin i - let mut xp = xp_before; - xp[i] += scale(i, dx); - - // Solve for new y[j] - let y_new = newton_y([ann, gamma], &xp, d, j); - - // dy (scaled) = old_y - new_y - 1 - let dy_scaled = xp_before[j].checked_sub(y_new)?; - let dy_scaled = dy_scaled.saturating_sub(U256::from(1)); - - // Dynamic fee - let fee = compute_dynamic_fee(&xp, fee_mid, fee_out); - let fee_amount = dy_scaled * fee / U256::from(FEE_DENOMINATOR); - let dy_after_fee = dy_scaled.saturating_sub(fee_amount); - - // Unscale to native decimals - let dy_native = if j == 0 { - if precision_multipliers[j].is_zero() { - return None; - } - dy_after_fee / precision_multipliers[j] - } else { - if precision_multipliers[j].is_zero() || price_scale[j - 1].is_zero() { - return None; - } - dy_after_fee * one / price_scale[j - 1] / precision_multipliers[j] - }; - - Some(dy_native) -} - -/// Dynamic fee: linear interpolation between fee_mid (balanced) and fee_out (imbalanced). -fn compute_dynamic_fee(xp: &[U256; 2], fee_mid: U256, fee_out: U256) -> U256 { - let one = e18(); - let s = xp[0] + xp[1]; - if s.is_zero() { - return fee_mid; - } - - // K0 = 4 * x0 * x1 * 1e18 / S^2 ∈ [0, 1e18] - let k0 = sdiv(sdiv(U256::from(4) * xp[0] * one, s) * xp[1], s).min(one); - - if fee_out <= fee_mid { - return fee_mid; - } - fee_mid + (fee_out - fee_mid) * (one - k0) / one -} - -#[cfg(test)] -mod tests { - use super::*; - - fn u(val: u128) -> U256 { - U256::from(val) - } - - #[test] - fn test_newton_d_symmetric() { - let one = u(1_000_000_000_000_000_000); - let xp = [one, one]; - let d = newton_d(&xp, [u(400_000), u(145_000_000_000_000)]); - let d_val: u128 = d.try_into().unwrap(); - assert!(d_val > 1_900_000_000_000_000_000, "D too low: {}", d_val); - assert!(d_val < 2_100_000_000_000_000_000, "D too high: {}", d_val); - } - - #[test] - fn test_newton_d_balanced() { - // newton_d should produce D ≈ 2000e18 for balanced 1000/1000 pool - let one = u(1_000_000_000_000_000_000); - let d = newton_d( - &[one * u(1000), one * u(1000)], - [u(4_000_000_000), u(145_000_000_000_000)], // ANN=4e9, gamma=1.45e14 - ); - let d_val: u128 = d.try_into().unwrap(); - assert!( - d_val > 1_999_000_000_000_000_000_000u128, - "D too low: {}", - d_val - ); - assert!( - d_val < 2_001_000_000_000_000_000_000u128, - "D too high: {}", - d_val - ); - } - - #[test] - fn test_newton_y_small_swap() { - // Swap 1 token in a 1000/1000 pool with high A — dy should be ~1 token - let one = u(1_000_000_000_000_000_000); - let xp_before = [one * u(1000), one * u(1000)]; - let a_gamma = [u(4_000_000_000), u(145_000_000_000_000)]; - let d = newton_d(&xp_before, a_gamma); - - // Add 1 token to coin 0, solve for new coin 1 balance - let xp = [xp_before[0] + one, xp_before[1]]; - let y_new = newton_y(a_gamma, &xp, d, 1); - let dy = xp_before[1].checked_sub(y_new).unwrap_or(U256::ZERO); - // For a high-A pool, dy ≈ 1e18 (close to dx) - assert!(dy > u(990_000_000_000_000_000), "dy too low: {}", dy); - assert!(dy < u(1_010_000_000_000_000_000), "dy too high: {}", dy); - } - - #[test] - fn test_cryptoswap_symmetric_pool() { - // 2-coin pool, both 18 dec, price_scale=1e18 (parity), large reserves - let reserves = vec![ - u(1_000_000_000_000_000_000_000), // 1000 tokens - u(1_000_000_000_000_000_000_000), - ]; - let pm = vec![u(1), u(1)]; - let ps = vec![u(1_000_000_000_000_000_000)]; // 1:1 - let a = u(400_000); - let gamma = u(145_000_000_000_000); - - let dx = u(1_000_000_000_000_000_000); // 1 token - let result = cryptoswap_get_dy( - &reserves, - &pm, - a, - gamma, - &ps, - u(3_000_000), - u(30_000_000), - 0, - 1, - dx, - ); - assert!(result.is_some(), "should produce output"); - let dy_val: u128 = result.unwrap().try_into().unwrap(); - // Near 1:1 for balanced pool with small trade - assert!(dy_val > 900_000_000_000_000_000, "too low: {}", dy_val); - assert!( - dy_val < 1_000_000_000_000_000_000, - "should be < 1 due to fee" - ); - } - - #[test] - fn test_cryptoswap_get_dy_weth_usdc() { - // USDC (6 dec) = coin 0, WETH (18 dec) = coin 1 - // price_scale = price of WETH in USDC = 2000 (in 1e18 fixed point) - // This makes xp balanced: xp[0]=200000e18, xp[1]=100*2000=200000e18 - let reserves = vec![ - u(200_000_000_000), // 200K USDC (6 dec) - u(100_000_000_000_000_000_000), // 100 WETH (18 dec) - ]; - let pm = vec![u(1_000_000_000_000), u(1)]; // 10^12 for USDC, 1 for WETH - let ps = vec![u(2_000_000_000_000_000_000_000)]; // 2000e18 - let a = u(400_000); // stored _A = A_ext * 100 - let gamma = u(145_000_000_000_000); - - // Swap 1 WETH (coin 1) → USDC (coin 0) - let dx = u(1_000_000_000_000_000_000); // 1 WETH - let result = cryptoswap_get_dy( - &reserves, - &pm, - a, - gamma, - &ps, - u(3_000_000), - u(30_000_000), - 1, - 0, - dx, - ); - assert!(result.is_some(), "should produce output"); - let dy_val: u128 = result.unwrap().try_into().unwrap(); - // ~2000 USDC in 6 dec = ~2_000_000_000 - assert!( - dy_val > 1_500_000_000, - "too low: {} (expected ~2000 USDC)", - dy_val - ); - assert!( - dy_val < 2_500_000_000, - "too high: {} (expected ~2000 USDC)", - dy_val - ); - } - - #[test] - fn test_cryptoswap_zero_input() { - let reserves = vec![u(1_000_000_000_000_000_000), u(1_000_000_000_000_000_000)]; - let pm = vec![u(1), u(1)]; - let ps = vec![u(1_000_000_000_000_000_000)]; - assert_eq!( - cryptoswap_get_dy( - &reserves, - &pm, - u(400_000), - u(145_000_000_000_000), - &ps, - u(3_000_000), - u(30_000_000), - 0, - 1, - U256::ZERO, - ), - Some(U256::ZERO) - ); - } - - #[test] - fn test_cryptoswap_invalid_indices() { - let reserves = vec![u(1_000_000_000_000_000_000), u(1_000_000_000_000_000_000)]; - let pm = vec![u(1), u(1)]; - let ps = vec![u(1_000_000_000_000_000_000)]; - assert!( - cryptoswap_get_dy( - &reserves, - &pm, - u(400_000), - u(145_000_000_000_000), - &ps, - u(3_000_000), - u(30_000_000), - 0, - 0, - u(1_000), - ) - .is_none() - ); - } - - #[test] - fn test_dynamic_fee_balanced() { - let one = u(1_000_000_000_000_000_000); - let fee = compute_dynamic_fee(&[one, one], u(3_000_000), u(30_000_000)); - let fee_val: u128 = fee.try_into().unwrap(); - assert!(fee_val <= 5_000_000, "should be near mid_fee: {}", fee_val); - } - - #[test] - fn test_dynamic_fee_imbalanced() { - let one = u(1_000_000_000_000_000_000); - let fee = compute_dynamic_fee(&[one * u(10), one], u(3_000_000), u(30_000_000)); - let fee_val: u128 = fee.try_into().unwrap(); - assert!(fee_val > 3_000_000, "should be > mid_fee: {}", fee_val); - assert!(fee_val <= 30_000_000, "should be <= out_fee: {}", fee_val); - } -} diff --git a/src/curve_pool.rs b/src/curve_pool.rs deleted file mode 100644 index ed4eb32..0000000 --- a/src/curve_pool.rs +++ /dev/null @@ -1,335 +0,0 @@ -//! Curve pool with local StableSwap and CryptoSwap simulation. -//! -//! Supports both stableswap (int128 indices) and cryptoswap (uint256 indices) -//! pool variants. StableSwap uses Newton's method on the constant-sum/product -//! invariant; CryptoSwap uses a gamma-corrected variant for volatile pairs. - -use alloy_eips::BlockId; -use alloy_network::Network; -use alloy_primitives::{Address, B256, U256}; -use alloy_provider::Provider; -use alloy_rpc_types_eth::Log; -use amms::amms::{amm::AutomatedMarketMaker, balancer::BalancerError, error::AMMError}; - -use super::{cryptoswap_math, stableswap_math}; - -/// A Curve pool entry for direct `exchange()` calls. -/// -/// Tokens are listed in coin-index order: `tokens[0]` has coin index 0, -/// `tokens[1]` has coin index 1, etc. Swaps between any pair are supported. -#[derive(Debug, Clone)] -pub struct CurvePool { - pub address: Address, - /// Ordered list of tokens — position equals Curve coin index. - pub tokens: Vec
, - /// `false` = stableswap (int128 indices), `true` = cryptoswap (uint256 indices). - pub use_uint256: bool, - /// Per-coin reserves in raw token units. - pub reserves: Vec, - /// Amplification coefficient in Curve's internal format. - /// StableSwap: `_A() = A() * A_PRECISION` (multiply external A by 100). - /// CryptoSwap: on-chain A value (includes A_MULTIPLIER = 10000). - pub a: U256, - /// Swap fee in parts-per-1e10 (e.g. 4000000 = 0.04%). - pub fee: U256, - /// Precision multipliers to normalize each coin to 18 decimals. - /// `precision_multipliers[i] = 10^(18 - decimals[i])`. - pub precision_multipliers: Vec, - /// CryptoSwap gamma parameter (1e18 fixed point). None for stableswap pools. - pub gamma: Option, - /// CryptoSwap price_scale (1e18 fixed point per additional coin). - /// `price_scale[k-1]` = price of coin k relative to coin 0. - /// Empty for stableswap pools. - pub price_scale: Vec, - /// CryptoSwap out_fee (upper bound of dynamic fee range), parts-per-1e10. - /// For stableswap pools this is None and the flat `fee` is used. - pub fee_out: Option, -} - -impl CurvePool { - /// Look up the coin index for a given token address. - pub fn coin_index(&self, token: Address) -> Option { - self.tokens - .iter() - .position(|t| *t == token) - .map(|i| i as u32) - } - - /// Dispatch to cryptoswap math for uint256-indexed pools. - fn cryptoswap_get_dy(&self, i: usize, j: usize, dx: U256) -> Option { - let gamma = self.gamma?; - let fee_out = self.fee_out.unwrap_or(self.fee); - cryptoswap_math::cryptoswap_get_dy( - &self.reserves, - &self.precision_multipliers, - self.a, - gamma, - &self.price_scale, - self.fee, // mid_fee - fee_out, - i, - j, - dx, - ) - } - - /// Returns true if the pool has reserves populated. - pub fn is_initialized(&self) -> bool { - let base = !self.reserves.is_empty() - && self.reserves.len() == self.tokens.len() - && !self.a.is_zero() - && self.reserves.iter().any(|r| !r.is_zero()); - - if self.use_uint256 { - // CryptoSwap needs gamma + price_scale - base && self.gamma.is_some() && !self.price_scale.is_empty() - } else { - base - } - } -} - -impl AutomatedMarketMaker for CurvePool { - fn address(&self) -> Address { - self.address - } - - fn sync_events(&self) -> Vec { - vec![] - } - - fn sync(&mut self, _log: &Log) -> Result<(), AMMError> { - Ok(()) - } - - fn tokens(&self) -> Vec
{ - self.tokens.clone() - } - - fn calculate_price(&self, base: Address, quote: Address) -> Result { - if !self.is_initialized() { - return Err(AMMError::from(BalancerError::InitializationError)); - } - let i = self - .coin_index(base) - .ok_or(AMMError::from(BalancerError::TokenInDoesNotExist))? as usize; - let j = self - .coin_index(quote) - .ok_or(AMMError::from(BalancerError::TokenOutDoesNotExist))? as usize; - - // 1 unit in base token's native decimals - let pm_i: u128 = self.precision_multipliers[i].try_into().unwrap_or(1); - let one_unit = U256::from(1_000_000_000_000_000_000u128 / pm_i); - - let out = if self.use_uint256 { - self.cryptoswap_get_dy(i, j, one_unit).unwrap_or(U256::ZERO) - } else { - stableswap_math::stableswap_get_dy( - &self.reserves, - &self.precision_multipliers, - self.a, - self.fee, - i, - j, - one_unit, - ) - .unwrap_or(U256::ZERO) - }; - - let pm_j: u128 = self.precision_multipliers[j].try_into().unwrap_or(1); - let out_18: u128 = (out * U256::from(pm_j)).try_into().unwrap_or(0); - Ok(out_18 as f64 / 1e18) - } - - fn simulate_swap( - &self, - base: Address, - quote: Address, - amount_in: U256, - ) -> Result { - if !self.is_initialized() { - return Err(AMMError::from(BalancerError::InitializationError)); - } - - let i = self - .coin_index(base) - .ok_or(AMMError::from(BalancerError::TokenInDoesNotExist))? as usize; - let j = self - .coin_index(quote) - .ok_or(AMMError::from(BalancerError::TokenOutDoesNotExist))? as usize; - - if self.use_uint256 { - self.cryptoswap_get_dy(i, j, amount_in) - .ok_or_else(|| AMMError::from(BalancerError::InitializationError)) - } else { - stableswap_math::stableswap_get_dy( - &self.reserves, - &self.precision_multipliers, - self.a, - self.fee, - i, - j, - amount_in, - ) - .ok_or_else(|| AMMError::from(BalancerError::InitializationError)) - } - } - - fn simulate_swap_mut( - &mut self, - base: Address, - quote: Address, - amount_in: U256, - ) -> Result { - let result = self.simulate_swap(base, quote, amount_in)?; - - // Update reserves - let i = self.coin_index(base).unwrap() as usize; - let j = self.coin_index(quote).unwrap() as usize; - self.reserves[i] += amount_in; - self.reserves[j] = self.reserves[j].saturating_sub(result); - - Ok(result) - } - - async fn init(self, _block: BlockId, _provider: P) -> Result - where - N: Network, - P: Provider + Clone, - { - Ok(self) - } -} - -#[cfg(test)] -mod tests { - use super::stableswap_math::{get_d, stableswap_get_dy}; - use super::*; - - #[test] - fn test_stableswap_d_symmetric() { - // 2-pool with equal balances at 1e18 each, _A()=10000 (A=100) - let xp = vec![ - U256::from(1_000_000_000_000_000_000u128), - U256::from(1_000_000_000_000_000_000u128), - ]; - let d = get_d(&xp, U256::from(10_000)); // A=100 → _A()=10000 - // D should be approximately 2e18 - let d_val: u128 = d.try_into().unwrap(); - assert!(d_val > 1_999_000_000_000_000_000, "D too low: {}", d_val); - assert!(d_val < 2_001_000_000_000_000_000, "D too high: {}", d_val); - } - - #[test] - fn test_stableswap_get_dy_small() { - // Two stablecoins: USDC (6 dec) and USDT (6 dec), 1M each - // _A() = A * A_PRECISION = 200 * 100 = 20000 - let reserves = vec![ - U256::from(1_000_000_000_000u128), // 1M USDC - U256::from(1_000_000_000_000u128), // 1M USDT - ]; - let pm = vec![ - U256::from(1_000_000_000_000u128), // 10^12 (18-6) - U256::from(1_000_000_000_000u128), - ]; - let a = U256::from(20_000); // _A() = A(200) * 100 - let fee = U256::from(4_000_000u64); // 0.04% fee - - // Swap 1000 USDC -> USDT - let dx = U256::from(1_000_000_000u128); // 1000 USDC - let dy = stableswap_get_dy(&reserves, &pm, a, fee, 0, 1, dx).unwrap(); - let dy_val: u128 = dy.try_into().unwrap(); - assert!(dy_val > 999_000_000, "expected ~999 USDT, got {}", dy_val); - assert!( - dy_val < 1_000_000_000, - "expected <1000 USDT, got {}", - dy_val - ); - } - - #[test] - fn test_stableswap_imbalanced() { - // Pool with 2M USDC but only 500K USDT — price should deviate - let reserves = vec![ - U256::from(2_000_000_000_000u128), // 2M USDC - U256::from(500_000_000_000u128), // 500K USDT - ]; - let pm = vec![ - U256::from(1_000_000_000_000u128), - U256::from(1_000_000_000_000u128), - ]; - let a = U256::from(20_000); // _A() = A(200) * 100 - let fee = U256::from(4_000_000u64); - - // Swap 10K USDC -> USDT (selling the heavy coin) - let dx = U256::from(10_000_000_000u128); // 10K USDC - let dy = stableswap_get_dy(&reserves, &pm, a, fee, 0, 1, dx).unwrap(); - let dy_val: u128 = dy.try_into().unwrap(); - // Should get less than 10K USDT due to imbalance - assert!( - dy_val < 10_000_000_000, - "should be less than input: {}", - dy_val - ); - assert!( - dy_val > 9_000_000_000, - "should still be reasonable: {}", - dy_val - ); - } - - #[test] - fn test_cryptoswap_simulate_swap_dispatch_weth_usdc() { - // Mirror the parameters proven in - // `cryptoswap_math::tests::test_cryptoswap_get_dy_weth_usdc`, but drive - // them through `CurvePool::simulate_swap` to confirm a `use_uint256` - // pool reaches the cryptoswap dispatch (not the stableswap path). - // USDC (6 dec) = coin 0, WETH (18 dec) = coin 1. - let usdc = Address::repeat_byte(0x01); - let weth = Address::repeat_byte(0x02); - - let pool = CurvePool { - address: Address::repeat_byte(0xCC), - tokens: vec![usdc, weth], - use_uint256: true, - reserves: vec![ - U256::from(200_000_000_000u128), // 200K USDC (6 dec) - U256::from(100_000_000_000_000_000_000u128), // 100 WETH (18 dec) - ], - a: U256::from(400_000u64), // stored _A = A_ext * 100 - fee: U256::from(3_000_000u64), // mid_fee - precision_multipliers: vec![ - U256::from(1_000_000_000_000u128), // 10^12 for USDC - U256::from(1u64), // 1 for WETH - ], - gamma: Some(U256::from(145_000_000_000_000u128)), - price_scale: vec![U256::from(2_000_000_000_000_000_000_000u128)], // 2000e18 - fee_out: Some(U256::from(30_000_000u64)), - }; - - // A fully-specified cryptoswap pool must report itself initialized so - // simulation does not short-circuit with an error. - assert!(pool.is_initialized()); - - // Swap 1 WETH -> USDC. This must dispatch to the cryptoswap path - // because `use_uint256` is true. - let dx = U256::from(1_000_000_000_000_000_000u128); // 1 WETH - let dy = pool - .simulate_swap(weth, usdc, dx) - .expect("cryptoswap simulate_swap should produce output"); - - let dy_val: u128 = dy.try_into().unwrap(); - assert!(dy_val > 0, "output should be non-zero"); - // ~2000 USDC in 6 dec = ~2_000_000_000. - assert!( - dy_val > 1_500_000_000, - "too low: {} (expected ~2000 USDC)", - dy_val - ); - assert!( - dy_val < 2_500_000_000, - "too high: {} (expected ~2000 USDC)", - dy_val - ); - } -} diff --git a/src/data.rs b/src/data.rs deleted file mode 100644 index 64b74a9..0000000 --- a/src/data.rs +++ /dev/null @@ -1,78 +0,0 @@ -use alloy_primitives::{Address, B256}; -use alloy_provider::{MulticallError, Provider}; -use alloy_sol_types::sol; - -pub use amm_math::data::{PoolParams, PoolTokenParams}; - -sol! { - #[sol(rpc)] - contract IBalancerPool { - function getNormalizedWeights() external view returns (uint256[] memory); - function getSwapFeePercentage() external view returns (uint256); - } -} - -sol! { - #[sol(rpc)] - contract IBalancerVault { - function getPoolTokens(bytes32 poolId) - external - view - returns (address[] memory tokens, uint256[] memory balances, uint256 lastChangeBlock); - } -} - -sol! { - #[sol(rpc)] - contract IUniswapV3Pool { - function fee() external view returns (uint24); - function slot0() - external - view - returns ( - uint160 sqrtPriceX96, - int24 tick, - uint16 observationIndex, - uint16 observationCardinality, - uint16 observationCardinalityNext, - uint8 feeProtocol, - bool unlocked - ); - } -} - -pub async fn get_bal_pool_params( - provider: &P, - vault: Address, - pool_id: B256, -) -> Result { - let pool_addr = Address::from_slice(&pool_id.0[0..20]); - let pool = IBalancerPool::IBalancerPoolInstance::new(pool_addr, provider); - let vault = IBalancerVault::IBalancerVaultInstance::new(vault, provider); - - let multicall = provider - .multicall() - .add(vault.getPoolTokens(pool_id)) - .add(pool.getNormalizedWeights()) - .add(pool.getSwapFeePercentage()); - - let (pool_tokens, normalized_weights, swap_fee) = multicall.aggregate().await?; - - Ok(PoolParams::new_from_parts( - pool_tokens.tokens, - pool_tokens.balances, - normalized_weights, - swap_fee, - )) -} - -pub async fn get_uniswap_v3_slot0( - provider: &P, - pool_address: Address, -) -> Result<(IUniswapV3Pool::slot0Return, f64), MulticallError> { - let pool = IUniswapV3Pool::IUniswapV3PoolInstance::new(pool_address, provider); - let multicall = provider.multicall().add(pool.fee()).add(pool.slot0()); - let (fee, slot0) = multicall.aggregate().await?; - let fee_fraction = fee.to::() as f64 / 1_000_000.0_f64; - Ok((slot0, fee_fraction)) -} diff --git a/src/discovery.rs b/src/discovery.rs deleted file mode 100644 index 681c748..0000000 --- a/src/discovery.rs +++ /dev/null @@ -1,785 +0,0 @@ -//! Factory-based AMM discovery. -//! -//! Queries on-chain factory contracts to find pools for caller-provided token pairs -//! that are not already in the configured AMM set. Discovered pools are filtered -//! by liquidity (non-zero) and returned as initialized AMM state. -//! -//! Token pair generation is intentionally caller-owned so this crate stays -//! independent from any particular strategy, search, or execution system. - -use std::collections::{HashMap, HashSet}; -use std::sync::{Arc, RwLock}; - -use alloy_primitives::{Address, Signed, U256, Uint}; -use alloy_sol_types::SolCall; -use amms::amms::amm::AutomatedMarketMaker; -use anyhow::Result; -use tracing::{debug, info}; - -use evm_fork_cache::cache::EvmCache; -use evm_fork_cache::multicall::try_decode_result; - -use crate::amm_wrapper::LocalAMM; -use crate::cache_sync::{ - init_pancakeswap_v3_from_cache, init_slipstream_from_cache, init_uniswap_v2_from_cache, - init_uniswap_v3_from_cache, -}; -use crate::tuning::ProtocolAddresses; - -/// Type alias for AMM references used throughout the system. -pub type AMMRef = Arc>; - -alloy_sol_types::sol! { - interface IUniswapV3Factory { - function getPool(address tokenA, address tokenB, uint24 fee) - external view returns (address pool); - } - - interface ISlipstreamFactory { - function getPool(address tokenA, address tokenB, int24 tickSpacing) - external view returns (address pool); - } - - interface IUniswapV2Factory { - function getPair(address tokenA, address tokenB) - external view returns (address pair); - } -} - -/// PancakeSwap V3 has different fee tiers from Uniswap V3. -const PANCAKE_V3_FEE_TIERS: &[u32] = &[100, 500, 2500, 10000]; - -/// Common Slipstream (Aerodrome/Velodrome CL) tick spacings. -const SLIPSTREAM_TICK_SPACINGS: &[i32] = &[1, 50, 100, 200]; - -/// Default V2 fee in basis points (0.3%). -const DEFAULT_V2_FEE: u32 = 300; - -/// Factory discovery settings. -#[derive(Debug, Clone)] -pub struct FactoryDiscoveryConfig { - pub protocol_addresses: ProtocolAddresses, - pub uniswap_v3_fee_tiers: Vec, - pub pancake_v3_fee_tiers: Vec, - pub v2_fee_bps: u32, -} - -impl FactoryDiscoveryConfig { - pub fn new(protocol_addresses: ProtocolAddresses, uniswap_v3_fee_tiers: Vec) -> Self { - Self { - protocol_addresses, - uniswap_v3_fee_tiers, - pancake_v3_fee_tiers: PANCAKE_V3_FEE_TIERS.to_vec(), - v2_fee_bps: DEFAULT_V2_FEE, - } - } -} - -/// Display metadata used for discovery logs and TOML suggestions. -#[derive(Debug, Clone)] -pub struct TokenDisplayMetadata { - pub symbol: String, - pub decimals: u8, -} - -/// The type of pool discovered from a factory. -#[derive(Debug, Clone, Copy)] -enum DiscoveredPoolType { - UniswapV3, - PancakeSwapV3, - Slipstream { tick_spacing: i32 }, - UniswapV2 { fee: u32 }, -} - -/// A pool candidate returned by a factory query, before initialization. -#[derive(Debug, Clone)] -struct FactoryPoolCandidate { - address: Address, - pool_type: DiscoveredPoolType, -} - -/// Result of a discovery run. -pub struct DiscoveryResult { - pub discovered: Vec<(Address, LocalAMM)>, - pub skipped_zero_liquidity: usize, - pub skipped_already_known: usize, - pub total_queried: usize, -} - -/// Canonical pair ordering to avoid duplicates. -pub fn canonical_pair(a: Address, b: Address) -> (Address, Address) { - if a < b { (a, b) } else { (b, a) } -} - -/// Generate WETH-centric token pairs: every known token paired with WETH. -/// -/// The caller can also add any other application-specific pairs (e.g. pairs -/// observed in recent swaps, or per-route token-set pairs for multi-hop -/// shortcuts) before passing the set to [`discover_factory_amms`]. -pub fn generate_weth_centric_pairs( - active_tokens: &HashSet
, - weth: Address, -) -> HashSet<(Address, Address)> { - let mut pairs = HashSet::new(); - for token in active_tokens { - if *token != weth { - pairs.insert(canonical_pair(*token, weth)); - } - } - pairs -} - -/// Query a V3-style factory (Uniswap V3 or PancakeSwap V3) for pools. -fn query_v3_factory_pools( - cache: &mut EvmCache, - factory: Address, - pairs: &[(Address, Address)], - fee_tiers: &[u32], - pool_type_fn: fn(u32) -> DiscoveredPoolType, -) -> Result> { - let total_calls = pairs.len() * fee_tiers.len(); - if total_calls == 0 { - return Ok(Vec::new()); - } - - let mut call_meta: Vec = Vec::with_capacity(total_calls); - let mut calls: Vec<(Address, alloy_primitives::Bytes, bool)> = Vec::with_capacity(total_calls); - - for (token_a, token_b) in pairs { - for &fee in fee_tiers { - let call = IUniswapV3Factory::getPoolCall { - tokenA: *token_a, - tokenB: *token_b, - fee: Uint::from(fee), - }; - calls.push((factory, call.abi_encode().into(), true)); - call_meta.push(fee); - } - } - - let results = evm_fork_cache::multicall::execute_batched(cache, calls)?; - - let mut candidates = Vec::new(); - for (i, result) in results.iter().enumerate() { - if let Some(pool_addr) = try_decode_result::(result) - && pool_addr != Address::ZERO - { - candidates.push(FactoryPoolCandidate { - address: pool_addr, - pool_type: pool_type_fn(call_meta[i]), - }); - } - } - - Ok(candidates) -} - -/// Query a Slipstream factory for pools. -fn query_slipstream_factory_pools( - cache: &mut EvmCache, - factory: Address, - pairs: &[(Address, Address)], -) -> Result> { - let total_calls = pairs.len() * SLIPSTREAM_TICK_SPACINGS.len(); - if total_calls == 0 { - return Ok(Vec::new()); - } - - let mut call_meta: Vec = Vec::with_capacity(total_calls); - let mut calls: Vec<(Address, alloy_primitives::Bytes, bool)> = Vec::with_capacity(total_calls); - - for (token_a, token_b) in pairs { - for &ts in SLIPSTREAM_TICK_SPACINGS { - let call = ISlipstreamFactory::getPoolCall { - tokenA: *token_a, - tokenB: *token_b, - tickSpacing: Signed::try_from(ts).unwrap_or_default(), - }; - calls.push((factory, call.abi_encode().into(), true)); - call_meta.push(ts); - } - } - - let results = evm_fork_cache::multicall::execute_batched(cache, calls)?; - - let mut candidates = Vec::new(); - for (i, result) in results.iter().enumerate() { - if let Some(pool_addr) = try_decode_result::(result) - && pool_addr != Address::ZERO - { - candidates.push(FactoryPoolCandidate { - address: pool_addr, - pool_type: DiscoveredPoolType::Slipstream { - tick_spacing: call_meta[i], - }, - }); - } - } - - Ok(candidates) -} - -/// Query a V2-style factory for pools. -fn query_v2_factory_pools( - cache: &mut EvmCache, - factory: Address, - pairs: &[(Address, Address)], - fee: u32, -) -> Result> { - if pairs.is_empty() { - return Ok(Vec::new()); - } - - let calls: Vec<(Address, alloy_primitives::Bytes, bool)> = pairs - .iter() - .map(|(token_a, token_b)| { - let call = IUniswapV2Factory::getPairCall { - tokenA: *token_a, - tokenB: *token_b, - }; - (factory, call.abi_encode().into(), true) - }) - .collect(); - - let results = evm_fork_cache::multicall::execute_batched(cache, calls)?; - - let mut candidates = Vec::new(); - for result in &results { - if let Some(pair_addr) = try_decode_result::(result) - && pair_addr != Address::ZERO - { - candidates.push(FactoryPoolCandidate { - address: pair_addr, - pool_type: DiscoveredPoolType::UniswapV2 { fee }, - }); - } - } - - Ok(candidates) -} - -/// Initialize discovered pool candidates and filter out zero-liquidity pools. -async fn init_and_filter_candidates( - cache: &mut EvmCache, - candidates: Vec, -) -> (Vec<(Address, LocalAMM)>, usize) { - let mut discovered = Vec::new(); - let mut skipped = 0usize; - - for candidate in candidates { - let result = match candidate.pool_type { - DiscoveredPoolType::UniswapV3 => init_uniswap_v3_from_cache(cache, candidate.address) - .await - .map(LocalAMM::UniswapV3), - DiscoveredPoolType::PancakeSwapV3 => { - init_pancakeswap_v3_from_cache(cache, candidate.address) - .await - .map(LocalAMM::PancakeSwapV3) - } - DiscoveredPoolType::Slipstream { tick_spacing } => { - init_slipstream_from_cache(cache, candidate.address, tick_spacing) - .await - .map(LocalAMM::Slipstream) - } - DiscoveredPoolType::UniswapV2 { fee } => { - init_uniswap_v2_from_cache(cache, candidate.address, fee as usize) - .await - .map(LocalAMM::UniswapV2) - } - }; - - match result { - Ok(amm) => { - if has_nonzero_liquidity(&amm) { - discovered.push((candidate.address, amm)); - } else { - debug!( - pool = %candidate.address, - pool_type = ?candidate.pool_type, - "Skipping discovered pool: zero liquidity" - ); - skipped += 1; - } - } - Err(e) => { - debug!( - pool = %candidate.address, - pool_type = ?candidate.pool_type, - error = %e, - "Failed to initialize discovered pool" - ); - skipped += 1; - } - } - } - - (discovered, skipped) -} - -/// Check if a pool has non-zero liquidity (usable for swaps). -fn has_nonzero_liquidity(amm: &LocalAMM) -> bool { - match amm { - LocalAMM::UniswapV2(pool) => pool.reserve_0 > 0 && pool.reserve_1 > 0, - LocalAMM::UniswapV3(pool) | LocalAMM::PancakeSwapV3(pool) => { - pool.liquidity > 0 && pool.sqrt_price != U256::ZERO - } - LocalAMM::Slipstream(pool) => pool.liquidity > 0 && pool.sqrt_price != U256::ZERO, - _ => true, - } -} - -/// Main entry point: discover AMMs from on-chain factories. -/// -/// `discovery_pairs` should contain all interesting token pairs to query — the caller -/// is responsible for assembling them from whatever sources are relevant (e.g. -/// WETH-centric pairs, recently observed pairs, and per-route token-set pairs; -/// use [`generate_weth_centric_pairs`] and [`canonical_pair`] as helpers). -pub async fn discover_factory_amms( - cache: &mut EvmCache, - discovery_pairs: &HashSet<(Address, Address)>, - config: &FactoryDiscoveryConfig, - existing_amms: &HashMap, -) -> Result { - let protocol_addrs = &config.protocol_addresses; - let pairs: Vec<(Address, Address)> = discovery_pairs.iter().copied().collect(); - - debug!(pair_count = pairs.len(), "Discovery token pairs"); - - if pairs.is_empty() { - return Ok(DiscoveryResult { - discovered: Vec::new(), - skipped_zero_liquidity: 0, - skipped_already_known: 0, - total_queried: 0, - }); - } - - // Query all configured factories - let mut all_candidates: Vec = Vec::new(); - let mut total_queried = 0usize; - - // Uniswap V3 factory - if let Some(factory) = protocol_addrs.uniswap_v3_factory { - cache.ensure_account(factory).await?; - let candidates = query_v3_factory_pools( - cache, - factory, - &pairs, - &config.uniswap_v3_fee_tiers, - |_fee| DiscoveredPoolType::UniswapV3, - )?; - total_queried += pairs.len() * config.uniswap_v3_fee_tiers.len(); - debug!(factory = %factory, candidates = candidates.len(), "UniswapV3 factory query"); - all_candidates.extend(candidates); - } - - // PancakeSwap V3 factory - if let Some(factory) = protocol_addrs.pancake_v3_factory { - cache.ensure_account(factory).await?; - let candidates = query_v3_factory_pools( - cache, - factory, - &pairs, - &config.pancake_v3_fee_tiers, - |_fee| DiscoveredPoolType::PancakeSwapV3, - )?; - total_queried += pairs.len() * config.pancake_v3_fee_tiers.len(); - debug!(factory = %factory, candidates = candidates.len(), "PancakeSwapV3 factory query"); - all_candidates.extend(candidates); - } - - // Slipstream factory - if let Some(factory) = protocol_addrs.slipstream_factory { - cache.ensure_account(factory).await?; - let candidates = query_slipstream_factory_pools(cache, factory, &pairs)?; - total_queried += pairs.len() * SLIPSTREAM_TICK_SPACINGS.len(); - debug!(factory = %factory, candidates = candidates.len(), "Slipstream factory query"); - all_candidates.extend(candidates); - } - - // V2 factories - if let Some(factory) = protocol_addrs.uniswap_v2_factory { - cache.ensure_account(factory).await?; - let candidates = query_v2_factory_pools(cache, factory, &pairs, config.v2_fee_bps)?; - total_queried += pairs.len(); - debug!(factory = %factory, candidates = candidates.len(), "UniswapV2 factory query"); - all_candidates.extend(candidates); - } - - if let Some(factory) = protocol_addrs.sushiswap_v2_factory { - cache.ensure_account(factory).await?; - let candidates = query_v2_factory_pools(cache, factory, &pairs, config.v2_fee_bps)?; - total_queried += pairs.len(); - debug!(factory = %factory, candidates = candidates.len(), "SushiSwapV2 factory query"); - all_candidates.extend(candidates); - } - - // Dedup and filter already-known pools - let mut skipped_already_known = 0usize; - let mut seen_addresses: HashSet
= HashSet::new(); - all_candidates.retain(|c| { - if existing_amms.contains_key(&c.address) || !seen_addresses.insert(c.address) { - skipped_already_known += 1; - false - } else { - true - } - }); - - debug!( - candidates = all_candidates.len(), - skipped_already_known, "Deduped factory candidates" - ); - - // Initialize and filter by liquidity - let (discovered, skipped_zero_liquidity) = - init_and_filter_candidates(cache, all_candidates).await; - - Ok(DiscoveryResult { - discovered, - skipped_zero_liquidity, - skipped_already_known, - total_queried, - }) -} - -/// Log each discovered pool at info level. -pub fn log_discovered_amms( - result: &DiscoveryResult, - token_metadata: &HashMap, -) { - for (addr, amm) in &result.discovered { - let tokens = amm.tokens(); - let token_names: Vec = tokens - .iter() - .map(|t| { - token_metadata - .get(t) - .map(|m| m.symbol.clone()) - .unwrap_or_else(|| format!("{:.8}", t)) - }) - .collect(); - - let type_label = match amm { - LocalAMM::UniswapV2(_) => "UniswapV2".to_string(), - LocalAMM::UniswapV3(p) => format!("UniswapV3(fee={})", p.fee), - LocalAMM::PancakeSwapV3(p) => format!("PancakeV3(fee={})", p.fee), - LocalAMM::Slipstream(p) => format!("Slipstream(ts={})", p.tick_spacing), - _ => "Unknown".to_string(), - }; - - info!( - pool = %addr, - pool_type = %type_label, - tokens = %token_names.join("/"), - "Discovered new AMM" - ); - } -} - -/// Format discovered pools as TOML entries for amms.toml. -pub fn format_suggested_toml(result: &DiscoveryResult, chain_name: &str) -> String { - let mut output = String::new(); - output.push_str(&format!( - "# Suggested AMM additions for {} ({} pools discovered)\n\n", - chain_name, - result.discovered.len() - )); - - for (addr, amm) in &result.discovered { - let tokens = amm.tokens(); - let tokens_str: Vec = tokens.iter().map(|t| format!(" \"{:?}\"", t)).collect(); - - match amm { - LocalAMM::UniswapV2(_) => { - output.push_str(&format!("[[amms.{}]]\n", chain_name)); - output.push_str("type = \"uniswap_v2\"\n"); - output.push_str(&format!("address = \"{:?}\"\n", addr)); - output.push_str(&format!("tokens = [\n{}\n]\n", tokens_str.join(",\n"))); - output.push_str("fee_tier = 300\n\n"); - } - LocalAMM::UniswapV3(pool) => { - output.push_str(&format!("[[amms.{}]]\n", chain_name)); - output.push_str("type = \"uniswap_v3\"\n"); - output.push_str(&format!("address = \"{:?}\"\n", addr)); - output.push_str(&format!("tokens = [\n{}\n]\n", tokens_str.join(",\n"))); - output.push_str(&format!("fee_tier = {}\n\n", pool.fee)); - } - LocalAMM::PancakeSwapV3(pool) => { - output.push_str(&format!("[[amms.{}]]\n", chain_name)); - output.push_str("type = \"pancake_v3\"\n"); - output.push_str(&format!("address = \"{:?}\"\n", addr)); - output.push_str(&format!("tokens = [\n{}\n]\n", tokens_str.join(",\n"))); - output.push_str(&format!("fee_tier = {}\n\n", pool.fee)); - } - LocalAMM::Slipstream(pool) => { - output.push_str(&format!("[[amms.{}]]\n", chain_name)); - output.push_str("type = \"slipstream\"\n"); - output.push_str(&format!("address = \"{:?}\"\n", addr)); - output.push_str(&format!("tokens = [\n{}\n]\n", tokens_str.join(",\n"))); - output.push_str(&format!("tick_spacing = {}\n\n", pool.tick_spacing)); - } - _ => {} - } - } - - output -} - -/// Information about a discovered AMM for interactive selection. -pub struct DiscoveredAmmInfo { - pub address: Address, - pub protocol: String, - pub fee_or_param: String, - pub token_symbols: Vec, - pub liquidity_display: String, - /// Whether the caller flagged this pool as of interest (see - /// `highlighted_amms` in [`build_discovered_amm_info`]). - pub highlighted: bool, -} - -/// Build display info for each discovered AMM. -/// -/// `highlighted_amms` is a caller-supplied set of addresses to mark as of -/// interest in the resulting rows (e.g. pools that appear in a route the caller -/// cares about); pass an empty set if not needed. -pub fn build_discovered_amm_info( - discovered: &[(Address, LocalAMM)], - token_metadata: &HashMap, - highlighted_amms: &HashSet
, -) -> Vec { - discovered - .iter() - .map(|(addr, amm)| { - let tokens = amm.tokens(); - let token_symbols: Vec = tokens - .iter() - .map(|t| { - token_metadata - .get(t) - .map(|m| m.symbol.clone()) - .unwrap_or_else(|| format!("{:.10}", t)) - }) - .collect(); - - let (protocol, fee_or_param) = match amm { - LocalAMM::UniswapV2(_) => ("UniswapV2".to_string(), "fee=30bps".to_string()), - LocalAMM::UniswapV3(p) => { - ("UniswapV3".to_string(), format!("fee={}bps", p.fee / 100)) - } - LocalAMM::PancakeSwapV3(p) => { - ("PancakeV3".to_string(), format!("fee={}bps", p.fee / 100)) - } - LocalAMM::Slipstream(p) => { - ("Slipstream".to_string(), format!("ts={}", p.tick_spacing)) - } - _ => ("Unknown".to_string(), String::new()), - }; - - let liquidity_display = format_pool_liquidity(amm, &tokens, token_metadata); - - DiscoveredAmmInfo { - address: *addr, - protocol, - fee_or_param, - token_symbols, - liquidity_display, - highlighted: highlighted_amms.contains(addr), - } - }) - .collect() -} - -/// Format liquidity display for a pool (e.g., "42 WETH + 120,000 USDC"). -fn format_pool_liquidity( - amm: &LocalAMM, - tokens: &[Address], - token_metadata: &HashMap, -) -> String { - match amm { - LocalAMM::UniswapV2(pool) => { - let r0 = format_reserve(pool.reserve_0, tokens.first().copied(), token_metadata); - let r1 = format_reserve(pool.reserve_1, tokens.get(1).copied(), token_metadata); - format!("{} + {}", r0, r1) - } - LocalAMM::SolidlyV2(pool) => { - let r0 = format_reserve(pool.reserve_0, tokens.first().copied(), token_metadata); - let r1 = format_reserve(pool.reserve_1, tokens.get(1).copied(), token_metadata); - format!("{} + {}", r0, r1) - } - LocalAMM::UniswapV3(pool) | LocalAMM::PancakeSwapV3(pool) => { - if pool.liquidity > 0 { - format!("liq={}", format_compact_number(pool.liquidity as f64)) - } else { - "liq=0".to_string() - } - } - LocalAMM::Slipstream(pool) => { - if pool.liquidity > 0 { - format!("liq={}", format_compact_number(pool.liquidity as f64)) - } else { - "liq=0".to_string() - } - } - _ => "N/A".to_string(), - } -} - -/// Format a u128 reserve with token decimals and symbol. -fn format_reserve( - reserve: u128, - token: Option
, - token_metadata: &HashMap, -) -> String { - let Some(addr) = token else { - return format!("{}", reserve); - }; - let meta = token_metadata.get(&addr); - let decimals = meta.map(|m| m.decimals).unwrap_or(18); - let symbol = meta.map(|m| m.symbol.as_str()).unwrap_or("???"); - let divisor = 10f64.powi(decimals as i32); - let human = reserve as f64 / divisor; - format!("{} {}", format_compact_number(human), symbol) -} - -/// Format a number compactly: 1,234,567 → "1.23M", 42,000 → "42K", 0.5 → "0.5". -fn format_compact_number(n: f64) -> String { - if n >= 1_000_000_000.0 { - format!("{:.2}B", n / 1_000_000_000.0) - } else if n >= 1_000_000.0 { - format!("{:.2}M", n / 1_000_000.0) - } else if n >= 1_000.0 { - format!("{:.1}K", n / 1_000.0) - } else if n >= 1.0 { - format!("{:.2}", n) - } else if n > 0.0 { - format!("{:.4}", n) - } else { - "0".to_string() - } -} - -/// Format a single AMM as a TOML entry for amms.toml. -pub fn format_single_amm_toml(addr: &Address, amm: &LocalAMM, chain_name: &str) -> Option { - let tokens = amm.tokens(); - let tokens_str: Vec = tokens.iter().map(|t| format!(" \"{:?}\"", t)).collect(); - let tokens_block = format!("tokens = [\n{}\n]", tokens_str.join(",\n")); - - let mut entry = String::new(); - entry.push_str(&format!("[[amms.{}]]\n", chain_name)); - - match amm { - LocalAMM::UniswapV2(_) => { - entry.push_str("type = \"uniswap_v2\"\n"); - entry.push_str(&format!("address = \"{:?}\"\n", addr)); - entry.push_str(&tokens_block); - entry.push('\n'); - entry.push_str("fee_tier = 300\n"); - } - LocalAMM::UniswapV3(pool) => { - entry.push_str("type = \"uniswap_v3\"\n"); - entry.push_str(&format!("address = \"{:?}\"\n", addr)); - entry.push_str(&tokens_block); - entry.push('\n'); - entry.push_str(&format!("fee_tier = {}\n", pool.fee)); - } - LocalAMM::PancakeSwapV3(pool) => { - entry.push_str("type = \"pancake_v3\"\n"); - entry.push_str(&format!("address = \"{:?}\"\n", addr)); - entry.push_str(&tokens_block); - entry.push('\n'); - entry.push_str(&format!("fee_tier = {}\n", pool.fee)); - } - LocalAMM::Slipstream(pool) => { - entry.push_str("type = \"slipstream\"\n"); - entry.push_str(&format!("address = \"{:?}\"\n", addr)); - entry.push_str(&tokens_block); - entry.push('\n'); - entry.push_str(&format!("tick_spacing = {}\n", pool.tick_spacing)); - } - _ => return None, - } - - Some(entry) -} - -/// Append selected AMMs to the amms.toml config file. -/// -/// Only appends — never removes existing entries. -pub fn append_amms_to_config(file_path: &std::path::Path, entries: &[String]) -> Result { - use std::io::Write; - - if entries.is_empty() { - return Ok(0); - } - - let mut file = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(file_path)?; - - // Ensure we start on a new line - writeln!(file)?; - for entry in entries { - writeln!(file, "{}", entry)?; - } - - Ok(entries.len()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_canonical_pair_ordering() { - let a = Address::repeat_byte(0x01); - let b = Address::repeat_byte(0x02); - assert_eq!(canonical_pair(a, b), canonical_pair(b, a)); - assert_eq!(canonical_pair(a, b), (a, b)); - } - - #[test] - fn test_generate_weth_centric_pairs() { - let weth = Address::repeat_byte(0xFF); - let token_a = Address::repeat_byte(0x01); - let token_b = Address::repeat_byte(0x02); - - let mut active_tokens = HashSet::new(); - active_tokens.insert(weth); - active_tokens.insert(token_a); - active_tokens.insert(token_b); - - let pairs = generate_weth_centric_pairs(&active_tokens, weth); - - assert!(pairs.contains(&canonical_pair(token_a, weth))); - assert!(pairs.contains(&canonical_pair(token_b, weth))); - assert_eq!(pairs.len(), 2); - } - - #[test] - fn test_token_set_pairs() { - // A token set {A, B, WETH, C} should produce C(4,2) = 6 unique pairs, - // including the direct (A,B) pair. - let a = Address::repeat_byte(0x01); - let b = Address::repeat_byte(0x02); - let weth = Address::repeat_byte(0xFF); - let c = Address::repeat_byte(0x03); - - let token_set: HashSet
= [a, b, weth, c].into_iter().collect(); - let mut pairs = HashSet::new(); - - let tokens: Vec
= token_set.into_iter().collect(); - for i in 0..tokens.len() { - for j in (i + 1)..tokens.len() { - pairs.insert(canonical_pair(tokens[i], tokens[j])); - } - } - - assert_eq!(pairs.len(), 6); - // The cross-path pair (A,B) must be present - assert!(pairs.contains(&canonical_pair(a, b))); - // The shortcut pair (A,C) must be present - assert!(pairs.contains(&canonical_pair(a, c))); - } -} diff --git a/src/events/mod.rs b/src/events/mod.rs deleted file mode 100644 index a4e825b..0000000 --- a/src/events/mod.rs +++ /dev/null @@ -1,711 +0,0 @@ -//! Event-driven pool state updates. -//! -//! This module turns on-chain logs into in-memory pool-state mutations so that -//! a set of [`LocalAMM`]s tracks live chain state without re-reading storage on -//! every block. It is the bridge between a log subscription (e.g. -//! `provider.subscribe_logs`) and the locally-simulatable pool models. -//! -//! Three pieces fit together: -//! -//! - [`apply_log`] decodes a single log and applies it to one pool, in place -//! and fully offline (no RPC). It supports every pool family the crate -//! models — Uniswap V2/V3, PancakeSwap V3, Solidly V2, Slipstream, Curve, -//! Balancer V2/V3, and ERC4626 — filling the gap left by the upstream `amms` -//! crate, whose `sync()` is a no-op for several of these types. -//! - [`EventRouter`] owns a set of pools keyed by address, builds the topic -//! filter to subscribe with ([`EventRouter::subscription_topics`]), routes -//! each incoming log to the right pool (handling vault-emitted Balancer -//! events), and applies it. [`EventRouter::snapshot`] produces an immutable, -//! `Send + Sync` copy of all pool states for offline parallel simulation. -//! - [`mirror_updates_to_cache`] pushes the freshly-applied state back into an -//! [`EvmCache`] so that EVM-level reads (e.g. `call_raw` quotes) see the same -//! values, reusing the crate's existing hot-state injection path. -//! -//! # Example -//! -//! ```ignore -//! let router = EventRouter::from_loaded(amms); -//! let topics = router.subscription_topics(); -//! let filter = Filter::new().event_signature(topics); -//! let mut stream = provider.subscribe_logs(&filter).await?.into_stream(); -//! while let Some(log) = stream.next().await { -//! if let Some(update) = router.apply(&log)? { -//! // Pools now reflect the new state; simulate offline. -//! let snapshot = router.snapshot(); -//! // ... -//! } -//! } -//! ``` - -use std::collections::HashMap; - -use alloy_primitives::{Address, B256, U256}; -use alloy_rpc_types_eth::Log; -use alloy_sol_types::{SolEvent, sol}; -use amms::amms::amm::AutomatedMarketMaker; -use evm_fork_cache::cache::{EvmCache, SlotObservationTracker}; - -use crate::amm_wrapper::{LocalAMM, Variant}; -use crate::cache_sync::{AMMRef, V3Flavor, inject_hot_state_to_evm, inject_v3_tick_data}; - -sol! { - interface IUniswapV2Events { - event Sync(uint112 reserve0, uint112 reserve1); - } - interface ISolidlyEvents { - event Sync(uint256 reserve0, uint256 reserve1); - } - interface IUniswapV3Events { - event Swap( - address indexed sender, - address indexed recipient, - int256 amount0, - int256 amount1, - uint160 sqrtPriceX96, - uint128 liquidity, - int24 tick - ); - event Mint( - address sender, - address indexed owner, - int24 indexed tickLower, - int24 indexed tickUpper, - uint128 amount, - uint256 amount0, - uint256 amount1 - ); - event Burn( - address indexed owner, - int24 indexed tickLower, - int24 indexed tickUpper, - uint128 amount, - uint256 amount0, - uint256 amount1 - ); - } - interface ICurveStableEvents { - event TokenExchange( - address indexed buyer, - int128 sold_id, - uint256 tokens_sold, - int128 bought_id, - uint256 tokens_bought - ); - } - interface ICurveCryptoEvents { - event TokenExchange( - address indexed buyer, - uint256 sold_id, - uint256 tokens_sold, - uint256 bought_id, - uint256 tokens_bought - ); - } - interface IBalancerV2Events { - event Swap( - bytes32 indexed poolId, - address indexed tokenIn, - address indexed tokenOut, - uint256 amountIn, - uint256 amountOut - ); - } - interface IBalancerV3Events { - event Swap( - address indexed pool, - address indexed tokenIn, - address indexed tokenOut, - uint256 amountIn, - uint256 amountOut, - uint256 swapFeePercentage, - uint256 swapFeeAmount - ); - } - interface IERC4626Events { - event Deposit(address indexed sender, address indexed owner, uint256 assets, uint256 shares); - event Withdraw( - address indexed sender, - address indexed receiver, - address indexed owner, - uint256 assets, - uint256 shares - ); - } -} - -/// What an applied log changed about a pool. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum UpdateKind { - /// A swap moved reserves/balances/price. For concentrated-liquidity pools - /// this also updates the active liquidity and current tick. - Swap, - /// A liquidity position was added or removed (Uniswap/Slipstream Mint/Burn, - /// ERC4626 deposit/withdraw). For V3-style pools the affected tick range is - /// carried so the cache mirror can re-inject exactly those ticks. - Liquidity { - /// Lower tick of the affected range (V3-style pools only; `0` otherwise). - tick_lower: i32, - /// Upper tick of the affected range (V3-style pools only; `0` otherwise). - tick_upper: i32, - }, -} - -/// The result of applying a single log to a pool. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct PoolUpdate { - /// Address of the pool whose state changed. - pub address: Address, - /// Family of the pool that was updated. - pub variant: Variant, - /// Nature of the change. - pub kind: UpdateKind, -} - -/// Errors that can occur while applying an event log. -#[derive(Debug)] -pub enum EventError { - /// The log had no `topic0` (anonymous event); cannot be routed. - MissingTopic, - /// The log's data was shorter than the expected ABI layout. - Truncated, - /// The log's payload could not be decoded against the expected ABI. - Decode(alloy_sol_types::Error), - /// The underlying pool rejected the log. - Amm(amms::amms::error::AMMError), -} - -impl std::fmt::Display for EventError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - EventError::MissingTopic => write!(f, "log has no topic0"), - EventError::Truncated => write!(f, "log data shorter than expected"), - EventError::Decode(e) => write!(f, "event decode error: {e}"), - EventError::Amm(e) => write!(f, "pool sync error: {e}"), - } - } -} - -impl std::error::Error for EventError {} - -impl From for EventError { - fn from(e: alloy_sol_types::Error) -> Self { - EventError::Decode(e) - } -} - -impl From for EventError { - fn from(e: amms::amms::error::AMMError) -> Self { - EventError::Amm(e) - } -} - -/// Read the 32-byte word at index `i` of a log's data, as a `U256`. -fn data_word(log: &Log, i: usize) -> Option { - let bytes = log.inner.data.data.as_ref(); - let start = i * 32; - bytes.get(start..start + 32).map(U256::from_be_slice) -} - -/// Interpret an indexed `int24` topic as an `i32`. -/// -/// Solidity sign-extends the value across the full 32-byte topic, so the low -/// four bytes already carry the correct two's-complement `i32` for any value in -/// the `int24` range. -fn topic_to_i32(topic: &B256) -> i32 { - let mut b = [0u8; 4]; - b.copy_from_slice(&topic.as_slice()[28..32]); - i32::from_be_bytes(b) -} - -/// The set of `topic0` event signatures relevant to a given pool family. -/// -/// These are the topics to subscribe to so that [`apply_log`] can keep a pool -/// of this family up to date. -pub fn event_topics_for(variant: Variant) -> Vec { - match variant { - Variant::UniswapV2 => vec![IUniswapV2Events::Sync::SIGNATURE_HASH], - Variant::UniswapV3 | Variant::PancakeSwapV3 | Variant::Slipstream => vec![ - IUniswapV3Events::Swap::SIGNATURE_HASH, - IUniswapV3Events::Mint::SIGNATURE_HASH, - IUniswapV3Events::Burn::SIGNATURE_HASH, - ], - Variant::SolidlyV2 => vec![ - ISolidlyEvents::Sync::SIGNATURE_HASH, - IUniswapV2Events::Sync::SIGNATURE_HASH, - ], - Variant::Curve => vec![ - ICurveStableEvents::TokenExchange::SIGNATURE_HASH, - ICurveCryptoEvents::TokenExchange::SIGNATURE_HASH, - ], - Variant::Balancer => vec![IBalancerV2Events::Swap::SIGNATURE_HASH], - Variant::BalancerV3 => vec![IBalancerV3Events::Swap::SIGNATURE_HASH], - Variant::ERC4626 => vec![ - IERC4626Events::Deposit::SIGNATURE_HASH, - IERC4626Events::Withdraw::SIGNATURE_HASH, - ], - // The Uniswap V4 wrapper is a non-functional stub; it has no events. - Variant::UniswapV4 => Vec::new(), - } -} - -/// Every event `topic0` the crate knows how to apply. -/// -/// Useful for a blanket subscription filter when the exact pool families are -/// not known ahead of time; logs from unrelated contracts are ignored by the -/// router's address lookup. -pub fn all_event_topics() -> Vec { - let mut topics = vec![ - IUniswapV2Events::Sync::SIGNATURE_HASH, - ISolidlyEvents::Sync::SIGNATURE_HASH, - IUniswapV3Events::Swap::SIGNATURE_HASH, - IUniswapV3Events::Mint::SIGNATURE_HASH, - IUniswapV3Events::Burn::SIGNATURE_HASH, - ICurveStableEvents::TokenExchange::SIGNATURE_HASH, - ICurveCryptoEvents::TokenExchange::SIGNATURE_HASH, - IBalancerV2Events::Swap::SIGNATURE_HASH, - IBalancerV3Events::Swap::SIGNATURE_HASH, - IERC4626Events::Deposit::SIGNATURE_HASH, - IERC4626Events::Withdraw::SIGNATURE_HASH, - ]; - topics.sort_unstable(); - topics.dedup(); - topics -} - -/// The pool address a log targets. -/// -/// Most pools emit their own events, so the target is `log.address()`. Balancer -/// is the exception: both the V2 and V3 vaults emit `Swap` on behalf of their -/// pools, identifying the pool in the first indexed topic (a `poolId` whose -/// first 20 bytes are the pool address for V2, or the pool address directly for -/// V3). -pub fn route_target(log: &Log) -> Option
{ - let topic0 = log.topics().first().copied()?; - if topic0 == IBalancerV2Events::Swap::SIGNATURE_HASH { - let pool_id = log.topics().get(1)?; - Some(Address::from_slice(&pool_id.as_slice()[0..20])) - } else if topic0 == IBalancerV3Events::Swap::SIGNATURE_HASH { - let pool = log.topics().get(1)?; - Some(Address::from_word(*pool)) - } else { - Some(log.address()) - } -} - -/// Apply a single log to a pool in place, fully offline. -/// -/// Returns `Ok(Some(update))` describing the change, `Ok(None)` if the log's -/// `topic0` is not relevant to this pool family, or an error if a relevant log -/// failed to decode/apply. -/// -/// # Per-family behaviour -/// -/// - **Uniswap V2 / PancakeSwap-V2-style**: `Sync` sets reserves exactly. -/// - **Uniswap V3 / PancakeSwap V3 / Slipstream**: `Swap` updates -/// price/tick/active-liquidity; `Mint`/`Burn` update the in-memory tick map, -/// tick bitmap and active liquidity (delegated to the `amms` tick math). -/// - **Solidly V2**: `Sync` sets reserves exactly. -/// - **Curve**: `TokenExchange` applies the swap deltas to per-coin reserves. -/// - **Balancer V2 / V3**: the vault `Swap` applies amount-in/out deltas to the -/// pool balances. -/// - **ERC4626**: `Deposit`/`Withdraw` adjust the asset/share reserves. -pub fn apply_log(amm: &mut LocalAMM, log: &Log) -> Result, EventError> { - let topic0 = log - .topics() - .first() - .copied() - .ok_or(EventError::MissingTopic)?; - let variant = amm.variant(); - let address = amm.address(); - - let kind = match amm { - LocalAMM::UniswapV2(pool) => { - if topic0 != IUniswapV2Events::Sync::SIGNATURE_HASH { - return Ok(None); - } - pool.sync(log)?; - Some(UpdateKind::Swap) - } - LocalAMM::UniswapV3(pool) | LocalAMM::PancakeSwapV3(pool) => apply_v3(pool, log, topic0)?, - LocalAMM::ERC4626(pool) => { - if topic0 != IERC4626Events::Deposit::SIGNATURE_HASH - && topic0 != IERC4626Events::Withdraw::SIGNATURE_HASH - { - return Ok(None); - } - pool.sync(log)?; - Some(UpdateKind::Liquidity { - tick_lower: 0, - tick_upper: 0, - }) - } - LocalAMM::Slipstream(pool) => { - let mut v3 = pool.as_v3_pool(); - let result = apply_v3(&mut v3, log, topic0)?; - if result.is_some() { - pool.apply_v3_state(&v3); - } - result - } - LocalAMM::SolidlyV2(pool) => { - if topic0 != ISolidlyEvents::Sync::SIGNATURE_HASH - && topic0 != IUniswapV2Events::Sync::SIGNATURE_HASH - { - return Ok(None); - } - let reserve0 = data_word(log, 0).ok_or(EventError::Truncated)?; - let reserve1 = data_word(log, 1).ok_or(EventError::Truncated)?; - pool.reserve_0 = reserve0.saturating_to(); - pool.reserve_1 = reserve1.saturating_to(); - Some(UpdateKind::Swap) - } - LocalAMM::Curve(pool) => apply_curve(pool, log, topic0)?, - LocalAMM::Balancer(pool) => { - if topic0 != IBalancerV2Events::Swap::SIGNATURE_HASH { - return Ok(None); - } - let ev = IBalancerV2Events::Swap::decode_log(log.as_ref())?; - if pool.apply_vault_swap(ev.tokenIn, ev.amountIn, ev.tokenOut, ev.amountOut) { - Some(UpdateKind::Swap) - } else { - None - } - } - LocalAMM::BalancerV3(pool) => { - if topic0 != IBalancerV3Events::Swap::SIGNATURE_HASH { - return Ok(None); - } - let ev = IBalancerV3Events::Swap::decode_log(log.as_ref())?; - // Resolve both legs against the modeled token pair first. A real V3 - // pool may hold tokens this 2-token model doesn't track; applying - // only one side would corrupt the balances, so it's all-or-nothing. - let index_of = |token: Address| -> Option { - if token == pool.token_a { - Some(0) - } else if token == pool.token_b { - Some(1) - } else { - None - } - }; - match (index_of(ev.tokenIn), index_of(ev.tokenOut)) { - (Some(i), Some(o)) => { - pool.balances[i] = pool.balances[i].saturating_add(ev.amountIn); - pool.balances[o] = pool.balances[o].saturating_sub(ev.amountOut); - Some(UpdateKind::Swap) - } - _ => None, - } - } - LocalAMM::UniswapV4(_) => None, - }; - - Ok(kind.map(|kind| PoolUpdate { - address, - variant, - kind, - })) -} - -/// Apply a Uniswap-V3-style log (Swap/Mint/Burn) to a V3 pool. -fn apply_v3( - pool: &mut amms::amms::uniswap_v3::UniswapV3Pool, - log: &Log, - topic0: B256, -) -> Result, EventError> { - if topic0 == IUniswapV3Events::Swap::SIGNATURE_HASH { - pool.sync(log)?; - Ok(Some(UpdateKind::Swap)) - } else if topic0 == IUniswapV3Events::Mint::SIGNATURE_HASH - || topic0 == IUniswapV3Events::Burn::SIGNATURE_HASH - { - // tickLower / tickUpper are the 2nd and 3rd indexed topics for both - // Mint and Burn. - let topics = log.topics(); - let tick_lower = topics.get(2).map(topic_to_i32).unwrap_or(0); - let tick_upper = topics.get(3).map(topic_to_i32).unwrap_or(0); - pool.sync(log)?; - Ok(Some(UpdateKind::Liquidity { - tick_lower, - tick_upper, - })) - } else { - Ok(None) - } -} - -/// Apply a Curve `TokenExchange` log to per-coin reserves. -/// -/// This is an approximation: the event reports the trader's `tokens_bought`, -/// but Curve's internal `balances()` are also reduced by the admin-fee share, -/// and cryptoswap pools additionally re-scale prices on each trade. The applied -/// reserves therefore drift slightly from on-chain state over many swaps and -/// should be reconciled periodically via `cache_sync::refresh_curve_reserves`. -fn apply_curve( - pool: &mut crate::curve_pool::CurvePool, - log: &Log, - topic0: B256, -) -> Result, EventError> { - if topic0 != ICurveStableEvents::TokenExchange::SIGNATURE_HASH - && topic0 != ICurveCryptoEvents::TokenExchange::SIGNATURE_HASH - { - return Ok(None); - } - // Both the int128 and uint256 variants lay out data identically for the - // first four words: sold_id, tokens_sold, bought_id, tokens_bought. - let missing = || { - EventError::Decode(alloy_sol_types::Error::Other( - "TokenExchange: short data".into(), - )) - }; - let sold_id: usize = data_word(log, 0).ok_or_else(missing)?.saturating_to(); - let tokens_sold = data_word(log, 1).ok_or_else(missing)?; - let bought_id: usize = data_word(log, 2).ok_or_else(missing)?.saturating_to(); - let tokens_bought = data_word(log, 3).ok_or_else(missing)?; - - let n = pool.reserves.len(); - if sold_id >= n || bought_id >= n { - return Ok(None); - } - pool.reserves[sold_id] = pool.reserves[sold_id].saturating_add(tokens_sold); - pool.reserves[bought_id] = pool.reserves[bought_id].saturating_sub(tokens_bought); - Ok(Some(UpdateKind::Swap)) -} - -/// A set of pools that can be kept current from a stream of event logs. -/// -/// The router owns each pool behind an [`AMMRef`] (`Arc>`), so -/// it can be cloned cheaply and shared across tasks. Incoming logs are routed -/// by [`route_target`] and applied with [`apply_log`]. -#[derive(Clone, Default)] -pub struct EventRouter { - pools: HashMap, -} - -impl EventRouter { - /// Build a router over pools already wrapped in [`AMMRef`]s. - /// - /// Pools are re-keyed by each one's canonical on-chain address - /// ([`AutomatedMarketMaker::address`]) so that log routing - /// ([`route_target`]) and the lookup key always agree. This matters for - /// Balancer, whose vault events identify the pool by id rather than by the - /// caller's map key. - pub fn new(pools: HashMap) -> Self { - let pools = pools - .into_values() - .map(|amm_ref| { - let address = amm_ref.read().expect("AMM lock poisoned").address(); - (address, amm_ref) - }) - .collect(); - Self { pools } - } - - /// Build a router from a map of owned pools, wrapping each in an [`AMMRef`] - /// and keying by the pool's canonical address. - pub fn from_amms(amms: HashMap) -> Self { - let pools = amms - .into_values() - .map(|amm| (amm.address(), AMMRef::new(amm.into()))) - .collect(); - Self { pools } - } - - /// Build a router from the output of the configured-AMM loaders - /// (`HashMap>`), dropping entries that failed to - /// load and keying by the pool's canonical address. - pub fn from_loaded(amms: HashMap>) -> Self { - let pools = amms - .into_values() - .flatten() - .map(|amm| (amm.address(), AMMRef::new(amm.into()))) - .collect(); - Self { pools } - } - - /// The pools this router tracks, keyed by address. - pub fn pools(&self) -> &HashMap { - &self.pools - } - - /// Number of pools tracked. - pub fn len(&self) -> usize { - self.pools.len() - } - - /// Whether the router tracks no pools. - pub fn is_empty(&self) -> bool { - self.pools.is_empty() - } - - /// The union of event `topic0`s across every tracked pool family. - /// - /// Pass these to a log subscription filter (e.g. - /// `Filter::new().event_signature(router.subscription_topics())`). - pub fn subscription_topics(&self) -> Vec { - let mut topics: Vec = self - .pools - .values() - .filter_map(|p| p.read().ok().map(|g| g.variant())) - .flat_map(event_topics_for) - .collect(); - topics.sort_unstable(); - topics.dedup(); - topics - } - - /// Route a log to its pool and apply it in place. - /// - /// Returns `Ok(None)` if the log targets a pool this router does not track - /// or is not relevant to that pool. - pub fn apply(&self, log: &Log) -> Result, EventError> { - let Some(target) = route_target(log) else { - return Ok(None); - }; - let Some(amm_ref) = self.pools.get(&target) else { - return Ok(None); - }; - let mut guard = amm_ref.write().expect("AMM lock poisoned"); - apply_log(&mut guard, log) - } - - /// Apply a batch of logs, returning every successful update. - /// - /// Decode/apply errors for individual logs are skipped so one malformed log - /// does not abort a whole block's worth of updates. - pub fn apply_all(&self, logs: &[Log]) -> Vec { - logs.iter() - .filter_map(|log| self.apply(log).ok().flatten()) - .collect() - } - - /// Take an immutable, `Send + Sync` snapshot of all pool states. - /// - /// The returned map is decoupled from the router's locks, making it ideal - /// for fully-offline parallel simulation (e.g. arbitrage search) while the - /// router keeps applying new events. - pub fn snapshot(&self) -> HashMap { - self.pools - .iter() - .filter_map(|(addr, amm_ref)| amm_ref.read().ok().map(|g| (*addr, g.clone()))) - .collect() - } -} - -/// Summary of a [`mirror_updates_to_cache`] call. -#[derive(Debug, Clone, Copy, Default)] -pub struct CacheMirrorSummary { - /// V2 pools whose reserves were injected directly into the cache. - pub v2_injected: usize, - /// V3-style pools whose slot0/liquidity were injected directly. - pub v3_injected: usize, - /// V3-style pools whose tick data was re-injected after a Mint/Burn. - pub ticks_injected: usize, - /// Pools that fell back to a storage purge (Balancer/Curve/Solidly/etc.). - pub purged: usize, -} - -/// Push applied updates into an [`EvmCache`] so EVM-level reads stay consistent. -/// -/// In-memory pool state is the source of truth for [`apply_log`]; this is only -/// needed when the same pools are also queried through the forked EVM (e.g. -/// `EvmCache::call_raw`). It reuses the crate's hot-state injection path: -/// -/// - **V2 / V3 / Slipstream** state is written into the cache exactly, with no -/// RPC. After a Mint/Burn the (already-correct) in-memory tick map is -/// re-injected via [`inject_v3_tick_data`]. -/// - **Balancer / Curve / ERC4626** state is invalidated (purged) so the next -/// EVM read lazily re-fetches it. -/// -/// `observations` should be a single tracker threaded across calls so the cache -/// can reason about which slots change frequently. -pub fn mirror_updates_to_cache( - cache: &mut EvmCache, - router: &EventRouter, - updates: &[PoolUpdate], - observations: &mut SlotObservationTracker, -) -> CacheMirrorSummary { - if updates.is_empty() { - return CacheMirrorSummary::default(); - } - - let mut addresses: Vec
= Vec::with_capacity(updates.len()); - let mut pending_tick_ranges: HashMap> = HashMap::new(); - for update in updates { - if !addresses.contains(&update.address) { - addresses.push(update.address); - } - if let UpdateKind::Liquidity { - tick_lower, - tick_upper, - } = update.kind - && (tick_lower != 0 || tick_upper != 0) - { - pending_tick_ranges - .entry(update.address) - .or_default() - .push((tick_lower, tick_upper)); - } - } - - let hot = inject_hot_state_to_evm( - cache, - router.pools(), - &addresses, - &pending_tick_ranges, - observations, - ); - - // After a Mint/Burn the in-memory tick map already reflects the new - // position, so mirror it into the cache exactly (no RPC resync). Skip pools - // whose slot0/liquidity injection failed (and was purged) — re-injecting - // ticks there would leave the tick map inconsistent with stale slot0 until - // the next read repopulates it from RPC. - let mut ticks_injected = 0usize; - for update in updates { - let UpdateKind::Liquidity { - tick_lower, - tick_upper, - } = update.kind - else { - continue; - }; - if tick_lower == 0 && tick_upper == 0 { - continue; // ERC4626 etc. — no tick map. - } - if hot.injection_failures.contains(&update.address) { - continue; - } - let Some(amm_ref) = router.pools().get(&update.address) else { - continue; - }; - let guard = amm_ref.read().expect("AMM lock poisoned"); - match &*guard { - LocalAMM::UniswapV3(pool) => { - inject_v3_tick_data(cache, update.address, pool, V3Flavor::UniswapV3); - ticks_injected += 1; - } - LocalAMM::PancakeSwapV3(pool) => { - inject_v3_tick_data(cache, update.address, pool, V3Flavor::PancakeSwapV3); - ticks_injected += 1; - } - LocalAMM::Slipstream(slip) => { - inject_v3_tick_data( - cache, - update.address, - &slip.as_v3_pool(), - V3Flavor::Slipstream, - ); - ticks_injected += 1; - } - _ => {} - } - } - - CacheMirrorSummary { - v2_injected: hot.v2_injected, - v3_injected: hot.v3_injected, - ticks_injected, - purged: hot.injection_failures.len(), - } -} diff --git a/src/lib.rs b/src/lib.rs index aad23c9..02e05db 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,83 +1,28 @@ -//! A real-time AMM state and routing engine, composed from -//! [`evm_fork_cache`] and [`amm_math`]. +//! A real-time AMM state engine built on a forked-EVM state cache +//! ([`evm_fork_cache`]). //! -//! `evm-amm-state` shows how to compose a forked-EVM state cache -//! ([`evm_fork_cache`]) with deterministic pool math ([`amm_math`]) into a -//! pipeline that tracks a set of AMMs, keeps them current from chain events, -//! and runs fast, parallel, fully-offline swap simulations against them. +//! `evm-amm-state` composes a forked-EVM state cache ([`evm_fork_cache`]) with a +//! set of protocol [`adapters`] into a pipeline that tracks a working set of +//! AMMs, cold-starts their on-chain state into the cache, keeps them current +//! from chain log events with no RPC, and runs fast, fully-offline swap +//! simulations against the live-synced state. //! //! The pieces, roughly in pipeline order: //! -//! - [`amm_wrapper::LocalAMM`] — a unified pool enum over Uniswap V2/V3, -//! PancakeSwap V3, Balancer V2/V3, Curve, Solidly V2, Slipstream, ERC4626, -//! and a Uniswap V4 stub, implementing the `amms` `AutomatedMarketMaker` -//! trait so every type simulates through one interface. -//! - [`configured_amms`] — load a working set of pools from an -//! [`evm_fork_cache::cache::EvmCache`], either from programmatically-built -//! [`configured_amms::AmmConfigEntry`] records or (optionally) from an -//! `amms.toml` file behind the `toml` feature. -//! - [`cache_sync`] — initialize and incrementally refresh each pool family -//! from forked storage, including adaptive V3 tick scanning. -//! - [`events`] — keep pools current from a log subscription: decode swaps and -//! liquidity events and apply them in place, with no RPC, then optionally -//! mirror the new state back into the cache. -//! - [`routing`] — enumerate and evaluate multi-leg routes (e.g. triangular -//! arbitrage) over an immutable pool snapshot, in parallel and fully offline. -//! - [`discovery`] — discover pools for caller-supplied token pairs from -//! configured factories. +//! - [`adapters`] — per-protocol adapters (Uniswap V2/V3, Balancer V2, …) over a +//! single [`adapters::AmmAdapter`] trait. Each adapter knows how to cold-start +//! a pool's storage into an [`evm_fork_cache::cache::EvmCache`], which log +//! events to subscribe to, how to apply those events reactively, and how to +//! `simulate_swap` against the cached state. The +//! [`adapters::AdapterRegistry`] dispatches by pool key, and +//! [`adapters::AmmReactiveHandler`] bridges the adapters into the +//! `evm_fork_cache` reactive runtime. +//! - [`tuning`] — always-on tuning knobs shared across the adapters path. //! -//! See the crate's `examples/` directory for an end-to-end bot that subscribes -//! to a set of pools and searches for 3-leg arbitrage on each update. +//! See the crate's `examples/adapter_pipeline.rs` for an end-to-end demo that +//! cold-starts a pool, subscribes to its events over a WebSocket endpoint, +//! applies them reactively, and simulates a swap against the live-synced state. // Always compiled — the adapter layer and lightweight utilities have no heavy deps. pub mod adapters; pub mod tuning; - -// Pure-Rust pool math + cache-sync initialization. Requires `amms` + `amm-math`. -// `progress` is a private helper used only by the simulation sync paths. -#[cfg(feature = "simulation")] -pub mod amm_wrapper; -#[cfg(feature = "simulation")] -pub mod balancer_pool; -#[cfg(feature = "simulation")] -pub mod balancer_v3_pool; -#[cfg(feature = "simulation")] -pub mod cache_sync; -#[cfg(feature = "simulation")] -pub mod configured_amms; -#[cfg(feature = "simulation")] -pub mod cryptoswap_math; -#[cfg(feature = "simulation")] -pub mod curve_pool; -#[cfg(feature = "simulation")] -pub mod data; -#[cfg(feature = "simulation")] -pub mod discovery; -#[cfg(feature = "simulation")] -pub mod events; -#[cfg(feature = "simulation")] -mod progress; -#[cfg(feature = "simulation")] -pub mod slipstream_pool; -#[cfg(feature = "simulation")] -pub mod solidly_v2_pool; -#[cfg(feature = "simulation")] -pub mod stableswap_math; -#[cfg(feature = "simulation")] -pub mod uniswap_v4_pool; - -// Parallel multi-leg route search. Requires `rayon` (and simulation). -#[cfg(feature = "search")] -pub mod routing; - -/// Re-export shared pool math for convenience. -#[cfg(feature = "simulation")] -pub mod balancer_math { - pub use amm_math::balancer_math::*; -} - -/// Re-export pure profit helpers for convenience. -#[cfg(feature = "simulation")] -pub mod profit { - pub use amm_math::profit::*; -} diff --git a/src/progress.rs b/src/progress.rs deleted file mode 100644 index d15f1a8..0000000 --- a/src/progress.rs +++ /dev/null @@ -1,30 +0,0 @@ -//! Minimal progress facade for long-running sync operations. - -use tracing::info; - -pub(crate) struct ProgressBar { - len: u64, - prefix: String, -} - -pub(crate) fn progress_bar(len: u64, prefix: &str) -> ProgressBar { - ProgressBar { - len, - prefix: prefix.to_string(), - } -} - -impl ProgressBar { - pub(crate) fn set_message(&self, _msg: impl Into) {} - - pub(crate) fn inc(&self, _delta: u64) {} -} - -pub(crate) fn finish_with_message(pb: &ProgressBar, msg: &str) { - info!( - prefix = pb.prefix, - total = pb.len, - message = msg, - "Progress checkpoint" - ); -} diff --git a/src/routing/mod.rs b/src/routing/mod.rs deleted file mode 100644 index 924f26f..0000000 --- a/src/routing/mod.rs +++ /dev/null @@ -1,446 +0,0 @@ -//! Offline, parallelizable multi-leg routing and triangular-arbitrage search. -//! -//! Everything here operates on an immutable snapshot of pool states -//! (`HashMap`) and uses only [`LocalAMM::simulate_swap`], -//! which is a pure function of each pool's in-memory fields. No RPC, no cache, -//! no async. That makes the search fully deterministic and trivially -//! parallelizable: candidate routes are evaluated concurrently with `rayon`, -//! each borrowing the shared snapshot immutably. -//! -//! The intended pairing is with [`crate::events::EventRouter`]: keep pools -//! current from a log stream, take [`EventRouter::snapshot`] when something -//! changes, then call [`find_triangular_arbitrage`] on the snapshot. -//! -//! [`LocalAMM::simulate_swap`]: crate::amm_wrapper::LocalAMM::simulate_swap -//! [`EventRouter::snapshot`]: crate::events::EventRouter::snapshot - -use std::collections::HashMap; - -use alloy_primitives::{Address, U256}; -use amms::amms::amm::AutomatedMarketMaker; -use rayon::prelude::*; - -use crate::amm_wrapper::LocalAMM; - -/// One directed hop: swap `token_in` for `token_out` through `pool`. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct Leg { - /// Address of the pool used for this hop. - pub pool: Address, - /// Token sent into the pool. - pub token_in: Address, - /// Token received from the pool. - pub token_out: Address, -} - -/// An ordered sequence of hops. A route is *cyclic* when the first leg's -/// `token_in` equals the last leg's `token_out`. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Route { - /// The hops, in execution order. - pub legs: Vec, -} - -impl Route { - /// The token fed into the first hop. - pub fn start_token(&self) -> Option
{ - self.legs.first().map(|l| l.token_in) - } - - /// The token produced by the last hop. - pub fn end_token(&self) -> Option
{ - self.legs.last().map(|l| l.token_out) - } - - /// Whether the route returns to its starting token. - pub fn is_cycle(&self) -> bool { - match (self.start_token(), self.end_token()) { - (Some(a), Some(b)) => a == b && !self.legs.is_empty(), - _ => false, - } - } - - /// Number of hops. - pub fn len(&self) -> usize { - self.legs.len() - } - - /// Whether the route has no hops. - pub fn is_empty(&self) -> bool { - self.legs.is_empty() - } -} - -/// A profitable (or break-even) sizing of a cyclic route. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ArbResult { - /// The cyclic route evaluated. - pub route: Route, - /// Input amount (in the start token's units) that produced this result. - pub amount_in: U256, - /// Output amount after traversing every hop. - pub amount_out: U256, - /// `amount_out - amount_in` (same token). Always `> 0` for results returned - /// by the search functions. - pub profit: U256, -} - -/// Index every token to the addresses of pools that hold it. -pub fn token_pool_index(pools: &HashMap) -> HashMap> { - let mut index: HashMap> = HashMap::new(); - for (addr, amm) in pools { - for token in amm.tokens() { - index.entry(token).or_default().push(*addr); - } - } - index -} - -/// Enumerate every simple 3-leg cycle `start → A → B → start` using three -/// distinct pools, where `A` and `B` are distinct from `start` and each other. -/// -/// Pools with more than two tokens (Curve, Balancer) contribute every valid -/// directed pair of their tokens, so multi-token pools are fully explored. -/// -/// The number of cycles grows quickly with the pool set, so prefer a focused -/// snapshot (e.g. pools touching a few tokens of interest) for large universes. -pub fn triangular_routes(pools: &HashMap, start: Address) -> Vec { - let index = token_pool_index(pools); - let mut routes = Vec::new(); - - let Some(first_pools) = index.get(&start) else { - return routes; - }; - - for &p1 in first_pools { - let Some(amm1) = pools.get(&p1) else { continue }; - for a in amm1.tokens() { - if a == start { - continue; - } - // leg 2: A -> B through a different pool - let Some(second_pools) = index.get(&a) else { - continue; - }; - for &p2 in second_pools { - if p2 == p1 { - continue; - } - let Some(amm2) = pools.get(&p2) else { continue }; - for b in amm2.tokens() { - if b == a || b == start { - continue; - } - // leg 3: B -> start through a third pool - let Some(third_pools) = index.get(&b) else { - continue; - }; - for &p3 in third_pools { - if p3 == p1 || p3 == p2 { - continue; - } - let Some(amm3) = pools.get(&p3) else { continue }; - if !amm3.tokens().contains(&start) { - continue; - } - routes.push(Route { - legs: vec![ - Leg { - pool: p1, - token_in: start, - token_out: a, - }, - Leg { - pool: p2, - token_in: a, - token_out: b, - }, - Leg { - pool: p3, - token_in: b, - token_out: start, - }, - ], - }); - } - } - } - } - } - - routes -} - -/// Simulate a route's output for `amount_in`, fully offline. -/// -/// Returns `None` if any hop is missing, errors, or yields zero — i.e. the -/// route is not viable at this size. -pub fn simulate_route( - pools: &HashMap, - route: &Route, - amount_in: U256, -) -> Option { - let mut amount = amount_in; - for leg in &route.legs { - let pool = pools.get(&leg.pool)?; - amount = pool - .simulate_swap(leg.token_in, leg.token_out, amount) - .ok()?; - if amount.is_zero() { - return None; - } - } - Some(amount) -} - -/// Profit of a cyclic route at `amount_in`, floored at zero. -fn route_profit(pools: &HashMap, route: &Route, amount_in: U256) -> U256 { - match simulate_route(pools, route, amount_in) { - Some(out) if out > amount_in => out - amount_in, - _ => U256::ZERO, - } -} - -/// Find the input size in `[min_in, max_in]` maximizing the profit of a cyclic -/// route, via ternary search over the profit curve. -/// -/// Returns the best strictly-profitable result, or `None` if the route is never -/// profitable in the range. The route must be a cycle (same start/end token); -/// non-cyclic routes (and an empty range) return `None`. -/// -/// Ternary search assumes the profit curve `out(x) - x` is unimodal. This holds -/// exactly for constant-product (Uniswap V2 / Solidly volatile) pools and is a -/// strong approximation for concentrated-liquidity and stable pools, whose -/// piecewise curves can in rare cases hide a better size near a tick/segment -/// boundary. For exhaustive sizing, feed your own candidate set to -/// [`simulate_route`]. -pub fn optimize_route( - pools: &HashMap, - route: &Route, - min_in: U256, - max_in: U256, -) -> Option { - if !route.is_cycle() || min_in > max_in { - return None; - } - - let mut lo = min_in.max(U256::from(1u64)); - let mut hi = max_in; - // After clamping `lo` up to 1, the range can be empty (e.g. `max_in == 0`). - if hi < lo { - return None; - } - - // Ternary search. Each step shrinks the interval by ~1/3; 256 steps covers - // the full U256 range with margin. Stop once the interval is small enough - // that the final endpoint sweep below covers every remaining integer. - let two = U256::from(2u64); - for _ in 0..256 { - if hi <= lo + two { - break; - } - let third = (hi - lo) / U256::from(3u64); - let m1 = lo + third; - let m2 = hi - third; - if route_profit(pools, route, m1) < route_profit(pools, route, m2) { - lo = m1; - } else { - hi = m2; - } - } - - // Evaluate the collapsed interval endpoints and midpoint; keep the best. - let mid = lo + (hi - lo) / two; - [lo, mid, hi] - .into_iter() - .filter_map(|amount_in| { - let out = simulate_route(pools, route, amount_in)?; - (out > amount_in).then(|| ArbResult { - route: route.clone(), - amount_in, - amount_out: out, - profit: out - amount_in, - }) - }) - .max_by(arb_ordering) -} - -/// A stable, deterministic key for a route: its sequence of pool addresses. -fn route_key(route: &Route) -> Vec
{ - route.legs.iter().map(|l| l.pool).collect() -} - -/// Total order over arbitrage results: higher profit is "greater"; ties break -/// toward a smaller input, then a lexicographically smaller route. Used so the -/// search returns a deterministic winner regardless of parallel iteration order. -fn arb_ordering(a: &ArbResult, b: &ArbResult) -> std::cmp::Ordering { - a.profit - .cmp(&b.profit) - .then_with(|| b.amount_in.cmp(&a.amount_in)) - .then_with(|| route_key(&b.route).cmp(&route_key(&a.route))) -} - -/// Search for the single most profitable triangular arbitrage starting and -/// ending at `start`, evaluating candidate cycles in parallel. -/// -/// `min_in`/`max_in` bound the trade size searched per cycle. Returns `None` -/// when no cycle is profitable. The winner is deterministic across runs (ties -/// broken by smaller input, then route order). -pub fn find_triangular_arbitrage( - pools: &HashMap, - start: Address, - min_in: U256, - max_in: U256, -) -> Option { - let routes = triangular_routes(pools, start); - routes - .par_iter() - .filter_map(|route| optimize_route(pools, route, min_in, max_in)) - .max_by(arb_ordering) -} - -/// Like [`find_triangular_arbitrage`] but returns every profitable cycle, -/// sorted best-first (by profit, then deterministic tie-breaks). -pub fn find_triangular_arbitrages( - pools: &HashMap, - start: Address, - min_in: U256, - max_in: U256, -) -> Vec { - let routes = triangular_routes(pools, start); - let mut results: Vec = routes - .par_iter() - .filter_map(|route| optimize_route(pools, route, min_in, max_in)) - .collect(); - results.sort_by(|a, b| arb_ordering(b, a)); - results -} - -#[cfg(test)] -mod tests { - use super::*; - use amms::amms::{Token, uniswap_v2::UniswapV2Pool}; - - fn token(byte: u8) -> Address { - Address::with_last_byte(byte) - } - - /// Build a Uniswap-V2 pool with 18-decimal tokens and a 0.3% fee. - fn v2(addr: u8, t0: Address, t1: Address, r0: u128, r1: u128) -> (Address, LocalAMM) { - let address = Address::with_last_byte(addr); - let pool = UniswapV2Pool { - address, - token_a: Token::new_with_decimals(t0, 18), - token_b: Token::new_with_decimals(t1, 18), - reserve_0: r0, - reserve_1: r1, - fee: 300, - }; - (address, LocalAMM::UniswapV2(pool)) - } - - const E18: u128 = 1_000_000_000_000_000_000; - - #[test] - fn enumerates_triangular_cycles() { - let (a, b, c) = (token(1), token(2), token(3)); - let pools: HashMap = [ - v2(10, a, b, 100 * E18, 100 * E18), - v2(11, b, c, 100 * E18, 100 * E18), - v2(12, c, a, 100 * E18, 100 * E18), - ] - .into_iter() - .collect(); - - let routes = triangular_routes(&pools, a); - // a->b->c->a and a->c->b->a are both valid 3-pool cycles. - assert_eq!(routes.len(), 2); - for route in &routes { - assert!(route.is_cycle()); - assert_eq!(route.len(), 3); - assert_eq!(route.start_token(), Some(a)); - } - } - - #[test] - fn finds_profitable_triangle() { - let (a, b, c) = (token(1), token(2), token(3)); - // a->b and b->c are 1:1; c->a pays out 2 a per c. Going a->b->c->a - // roughly doubles, so an arbitrage exists. - let pools: HashMap = [ - v2(10, a, b, 1000 * E18, 1000 * E18), - v2(11, b, c, 1000 * E18, 1000 * E18), - v2(12, c, a, 1000 * E18, 2000 * E18), - ] - .into_iter() - .collect(); - - let min_in = U256::from(E18); // 1 token - let max_in = U256::from(100_000) * U256::from(E18); // generously wide - let result = find_triangular_arbitrage(&pools, a, min_in, max_in) - .expect("expected a profitable cycle"); - - assert!(result.profit > U256::ZERO); - assert_eq!(result.route.start_token(), Some(a)); - assert_eq!(result.amount_out, result.amount_in + result.profit); - // The optimum is interior: strictly above the min bound and strictly - // below the (deliberately generous) max bound — so the search actually - // optimized rather than pinning to an edge. - assert!(result.amount_in > min_in); - assert!(result.amount_in < max_in); - } - - #[test] - fn empty_range_is_handled() { - let (a, b, c) = (token(1), token(2), token(3)); - let pools: HashMap = [ - v2(10, a, b, 1000 * E18, 1000 * E18), - v2(11, b, c, 1000 * E18, 1000 * E18), - v2(12, c, a, 1000 * E18, 2000 * E18), - ] - .into_iter() - .collect(); - - // max_in == 0 must not underflow or panic — it simply finds nothing. - assert!(find_triangular_arbitrage(&pools, a, U256::ZERO, U256::ZERO).is_none()); - // min_in > max_in is also a no-op. - assert!( - find_triangular_arbitrage(&pools, a, U256::from(10u64), U256::from(5u64)).is_none() - ); - } - - #[test] - fn search_is_deterministic() { - let (a, b, c) = (token(1), token(2), token(3)); - let pools: HashMap = [ - v2(10, a, b, 1000 * E18, 1000 * E18), - v2(11, b, c, 1000 * E18, 1000 * E18), - v2(12, c, a, 1000 * E18, 2000 * E18), - ] - .into_iter() - .collect(); - - let min_in = U256::from(E18); - let max_in = U256::from(100_000) * U256::from(E18); - let first = find_triangular_arbitrage(&pools, a, min_in, max_in); - for _ in 0..8 { - assert_eq!(find_triangular_arbitrage(&pools, a, min_in, max_in), first); - } - } - - #[test] - fn no_arbitrage_in_balanced_market() { - let (a, b, c) = (token(1), token(2), token(3)); - let pools: HashMap = [ - v2(10, a, b, 1000 * E18, 1000 * E18), - v2(11, b, c, 1000 * E18, 1000 * E18), - v2(12, c, a, 1000 * E18, 1000 * E18), - ] - .into_iter() - .collect(); - - // A perfectly balanced 1:1:1 triangle only loses money to fees. - let result = find_triangular_arbitrage(&pools, a, U256::from(E18), U256::from(100 * E18)); - assert!(result.is_none()); - } -} diff --git a/src/slipstream_pool.rs b/src/slipstream_pool.rs deleted file mode 100644 index c6445e2..0000000 --- a/src/slipstream_pool.rs +++ /dev/null @@ -1,152 +0,0 @@ -//! Aerodrome/Velodrome CL (Slipstream) pool implementation. -//! -//! Slipstream pools are concentrated liquidity pools with the same math as -//! UniswapV3 but use `tick_spacing` (not `fee`) for pool identification. -//! The fee is dynamic and read from the pool contract. -//! -//! This type wraps a UniswapV3Pool from the `amms` crate for simulation, -//! while maintaining the Slipstream-specific fields (tick spacing, dynamic -//! fee) that a downstream swap encoder and event handling need. - -use std::collections::HashMap; - -use alloy_eips::BlockId; -use alloy_network::Network; -use alloy_primitives::{Address, B256, U256}; -use alloy_provider::Provider; -use alloy_rpc_types_eth::Log; -use amms::amms::{ - Token, - amm::AutomatedMarketMaker, - error::AMMError, - uniswap_v3::{Info, UniswapV3Pool}, -}; - -#[derive(Debug, Clone)] -pub struct SlipstreamPool { - pub address: Address, - pub token_a: Address, - pub token_b: Address, - /// Tick spacing used for pool identification (NOT fee like UniswapV3). - pub tick_spacing: i32, - pub tick: i32, - pub sqrt_price: U256, - pub liquidity: u128, - /// Dynamic fee read from the pool contract (in hundredths of a bip, e.g. 3000 = 0.3%). - pub fee: u32, - /// Tick data for simulation (same format as UniswapV3). - pub ticks: HashMap, - /// Tick bitmap for simulation (same format as UniswapV3). - pub tick_bitmap: HashMap, - /// Decimals for token_a. - pub decimals_a: u8, - /// Decimals for token_b. - pub decimals_b: u8, -} - -impl SlipstreamPool { - /// Construct a SlipstreamPool from a V3 pool (used after tick resync). - pub fn from_v3_pool(v3: UniswapV3Pool, tick_spacing: i32) -> Self { - let tokens = v3.tokens(); - Self { - address: v3.address, - token_a: tokens[0], - token_b: tokens[1], - tick_spacing, - tick: v3.tick, - sqrt_price: v3.sqrt_price, - liquidity: v3.liquidity, - fee: v3.fee, - ticks: v3.ticks, - tick_bitmap: v3.tick_bitmap, - decimals_a: v3.token_a.decimals, - decimals_b: v3.token_b.decimals, - } - } - - /// Convert to a UniswapV3Pool for simulation purposes. - /// The V3 math is identical — only the fee source differs. - pub fn as_v3_pool(&self) -> UniswapV3Pool { - UniswapV3Pool { - address: self.address, - token_a: Token::new_with_decimals(self.token_a, self.decimals_a), - token_b: Token::new_with_decimals(self.token_b, self.decimals_b), - fee: self.fee, - tick: self.tick, - tick_spacing: self.tick_spacing, - liquidity: self.liquidity, - sqrt_price: self.sqrt_price, - ticks: self.ticks.clone(), - tick_bitmap: self.tick_bitmap.clone(), - } - } - - /// Update state from a V3 pool after simulation. - pub fn apply_v3_state(&mut self, v3: &UniswapV3Pool) { - self.tick = v3.tick; - self.sqrt_price = v3.sqrt_price; - self.liquidity = v3.liquidity; - self.ticks = v3.ticks.clone(); - self.tick_bitmap = v3.tick_bitmap.clone(); - } -} - -impl AutomatedMarketMaker for SlipstreamPool { - fn address(&self) -> Address { - self.address - } - - fn sync_events(&self) -> Vec { - // Slipstream uses the same event signatures as UniswapV3 - vec![alloy_primitives::keccak256( - "Swap(address,address,int256,int256,uint160,uint128,int24)", - )] - } - - fn sync(&mut self, _log: &Log) -> Result<(), AMMError> { - Ok(()) - } - - fn tokens(&self) -> Vec
{ - vec![self.token_a, self.token_b] - } - - fn calculate_price(&self, base: Address, quote: Address) -> Result { - self.as_v3_pool().calculate_price(base, quote) - } - - fn simulate_swap( - &self, - base: Address, - quote: Address, - amount_in: U256, - ) -> Result { - if self.liquidity == 0 || self.sqrt_price.is_zero() || self.ticks.is_empty() { - return Ok(U256::ZERO); - } - self.as_v3_pool().simulate_swap(base, quote, amount_in) - } - - fn simulate_swap_mut( - &mut self, - base: Address, - quote: Address, - amount_in: U256, - ) -> Result { - if self.liquidity == 0 || self.sqrt_price.is_zero() || self.ticks.is_empty() { - return Ok(U256::ZERO); - } - let mut v3 = self.as_v3_pool(); - let result = v3.simulate_swap_mut(base, quote, amount_in)?; - self.apply_v3_state(&v3); - Ok(result) - } - - async fn init(self, _block: BlockId, _provider: P) -> Result - where - N: Network, - P: Provider + Clone, - { - Ok(self) - } -} diff --git a/src/solidly_v2_pool.rs b/src/solidly_v2_pool.rs deleted file mode 100644 index cdcde4c..0000000 --- a/src/solidly_v2_pool.rs +++ /dev/null @@ -1,416 +0,0 @@ -//! Aerodrome/Velodrome classic (SolidlyV2) pool implementation. -//! -//! Supports both volatile (x*y=k) and stable (x³y+y³x=k) pool invariants. -//! Holds the stable/factory flags that a downstream swap encoder needs to -//! reconstruct the Solidly-style route struct. - -use alloy_eips::BlockId; -use alloy_network::Network; -use alloy_primitives::{Address, B256, U256}; -use alloy_provider::Provider; -use alloy_rpc_types_eth::Log; -use amms::amms::{amm::AutomatedMarketMaker, error::AMMError}; - -/// Fee denominator for Solidly V2 pools (fees are in basis points * 100). -/// Aerodrome/Velodrome use fee / 10_000 as the swap fee fraction. -const FEE_DENOMINATOR: u128 = 10_000; - -#[derive(Debug, Clone)] -pub struct SolidlyV2Pool { - pub address: Address, - pub token_a: Address, - pub token_b: Address, - /// Whether this is a stable (true) or volatile (false) pool. - pub stable: bool, - /// Factory address used in the Solidly router route struct. - pub factory: Address, - pub reserve_0: u128, - pub reserve_1: u128, - /// Fee in basis points (e.g. 30 = 0.30%). - pub fee: u32, - /// Decimals for token_a (needed for stable swap normalization). - pub decimals_0: u8, - /// Decimals for token_b (needed for stable swap normalization). - pub decimals_1: u8, -} - -impl SolidlyV2Pool { - /// Compute the output amount for a volatile (x*y=k) swap. - fn get_amount_out_volatile( - &self, - amount_in: u128, - reserve_in: u128, - reserve_out: u128, - ) -> u128 { - let amount_in_after_fee = U256::from(amount_in) - * U256::from(FEE_DENOMINATOR - self.fee as u128) - / U256::from(FEE_DENOMINATOR); - // x * y = k formula (use U256 to avoid u128 overflow on large reserves) - let numerator = amount_in_after_fee * U256::from(reserve_out); - let denominator = U256::from(reserve_in) + amount_in_after_fee; - if denominator.is_zero() { - return 0; - } - (numerator / denominator).to::() - } - - /// Compute the output amount for a stable (x³y+y³x=k) swap. - /// - /// Uses the Solidly stable swap invariant with decimal normalization. - /// The invariant is: x³y + y³x = k, where x and y are normalized to 18 decimals. - fn get_amount_out_stable( - &self, - amount_in: u128, - reserve_in: u128, - reserve_out: u128, - decimals_in: u8, - decimals_out: u8, - ) -> u128 { - let amount_in_after_fee_u256 = U256::from(amount_in) - * U256::from(FEE_DENOMINATOR - self.fee as u128) - / U256::from(FEE_DENOMINATOR); - let amount_in_after_fee: u128 = amount_in_after_fee_u256.to::(); - - // Normalize reserves and amount to 18 decimals - let dec_in = 10u128.pow(decimals_in as u32); - let dec_out = 10u128.pow(decimals_out as u32); - - // normalized reserves (18 decimal precision) - let _reserve_in_norm = - U256::from(reserve_in) * U256::from(10u128.pow(18)) / U256::from(dec_in); - let _reserve_out_norm = - U256::from(reserve_out) * U256::from(10u128.pow(18)) / U256::from(dec_out); - - // Use binary search to find output amount (same approach as on-chain _get_y) - let amount_in_norm = - U256::from(amount_in_after_fee) * U256::from(10u128.pow(18)) / U256::from(dec_in); - let new_reserve_in = _reserve_in_norm + amount_in_norm; - - // Compute k = f(reserve_in_norm, reserve_out_norm) - let k = self.compute_k(_reserve_in_norm, _reserve_out_norm); - - // Binary search for y such that f(new_reserve_in, y) = k - let y = self.get_y(new_reserve_in, k, _reserve_out_norm); - - // De-normalize the output - let amount_out_norm = _reserve_out_norm.saturating_sub(y); - let amount_out = amount_out_norm * U256::from(dec_out) / U256::from(10u128.pow(18)); - - // Clamp to u128 - amount_out.try_into().unwrap_or(u128::MAX) - } - - /// Compute the invariant k = x³y + y³x (all in 18-decimal normalized form). - fn compute_k(&self, x: U256, y: U256) -> U256 { - let e18 = U256::from(10u128.pow(18)); - // a = x * y / 1e18 - let a = x * y / e18; - // b = x² + y² (each divided by 1e18 to keep scale) - let b = (x * x / e18) + (y * y / e18); - // k = a * b / 1e18 - a * b / e18 - } - - /// Binary search for y such that compute_k(x, y) >= k. - fn get_y(&self, x: U256, k: U256, initial_y: U256) -> U256 { - let mut y = initial_y; - - // Newton's method: y_new = y - (f(x,y) - k) / f'(x,y)_dy - // f(x,y) = x³y + xy³ (normalized) - // f'_y = x³ + 3xy² (normalized) - for _ in 0..255 { - let k_current = self.compute_k(x, y); - if k_current < k { - // y is too small — increase - let dy = (k - k_current) * U256::from(10u128.pow(18)) / self.d_y(x, y); - if dy == U256::ZERO { - break; - } - y += dy; - } else { - // y is too large — decrease - let dy = (k_current - k) * U256::from(10u128.pow(18)) / self.d_y(x, y); - if dy == U256::ZERO { - break; - } - if dy > y { - break; - } - y -= dy; - } - } - y - } - - /// Derivative of k with respect to y: dk/dy = x³ + 3xy² (normalized). - fn d_y(&self, x: U256, y: U256) -> U256 { - let e18 = U256::from(10u128.pow(18)); - // x³/1e18² + 3*x*y²/1e18² - let x3 = x * x / e18 * x / e18; - let y2 = y * y / e18; - let three_xy2 = U256::from(3u64) * x * y2 / e18; - x3 + three_xy2 - } -} - -impl AutomatedMarketMaker for SolidlyV2Pool { - fn address(&self) -> Address { - self.address - } - - fn sync_events(&self) -> Vec { - // Solidly V2 pools emit Sync events identical to UniswapV2 - vec![alloy_primitives::keccak256("Sync(uint112,uint112)")] - } - - fn sync(&mut self, _log: &Log) -> Result<(), AMMError> { - Ok(()) - } - - fn tokens(&self) -> Vec
{ - vec![self.token_a, self.token_b] - } - - fn calculate_price(&self, base: Address, _quote: Address) -> Result { - if self.reserve_0 == 0 || self.reserve_1 == 0 { - return Ok(0.0); - } - if base == self.token_a { - Ok(self.reserve_1 as f64 / self.reserve_0 as f64) - } else { - Ok(self.reserve_0 as f64 / self.reserve_1 as f64) - } - } - - fn simulate_swap( - &self, - base: Address, - _quote: Address, - amount_in: U256, - ) -> Result { - if self.reserve_0 == 0 || self.reserve_1 == 0 { - return Ok(U256::ZERO); - } - - let amount_in_u128: u128 = amount_in.try_into().unwrap_or(u128::MAX); - - let (reserve_in, reserve_out, dec_in, dec_out) = if base == self.token_a { - ( - self.reserve_0, - self.reserve_1, - self.decimals_0, - self.decimals_1, - ) - } else { - ( - self.reserve_1, - self.reserve_0, - self.decimals_1, - self.decimals_0, - ) - }; - - let amount_out = if self.stable { - self.get_amount_out_stable(amount_in_u128, reserve_in, reserve_out, dec_in, dec_out) - } else { - self.get_amount_out_volatile(amount_in_u128, reserve_in, reserve_out) - }; - - Ok(U256::from(amount_out)) - } - - fn simulate_swap_mut( - &mut self, - base: Address, - quote: Address, - amount_in: U256, - ) -> Result { - let amount_out = self.simulate_swap(base, quote, amount_in)?; - - let amount_in_u128: u128 = amount_in.try_into().unwrap_or(u128::MAX); - let amount_out_u128: u128 = amount_out.try_into().unwrap_or(u128::MAX); - - if base == self.token_a { - self.reserve_0 = self.reserve_0.saturating_add(amount_in_u128); - self.reserve_1 = self.reserve_1.saturating_sub(amount_out_u128); - } else { - self.reserve_1 = self.reserve_1.saturating_add(amount_in_u128); - self.reserve_0 = self.reserve_0.saturating_sub(amount_out_u128); - } - - Ok(amount_out) - } - - async fn init(self, _block: BlockId, _provider: P) -> Result - where - N: Network, - P: Provider + Clone, - { - Ok(self) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn make_volatile_pool(r0: u128, r1: u128, fee: u32) -> SolidlyV2Pool { - SolidlyV2Pool { - address: Address::ZERO, - token_a: Address::with_last_byte(1), - token_b: Address::with_last_byte(2), - stable: false, - factory: Address::ZERO, - reserve_0: r0, - reserve_1: r1, - fee, - decimals_0: 18, - decimals_1: 18, - } - } - - fn make_stable_pool(r0: u128, r1: u128, fee: u32, dec0: u8, dec1: u8) -> SolidlyV2Pool { - SolidlyV2Pool { - address: Address::ZERO, - token_a: Address::with_last_byte(1), - token_b: Address::with_last_byte(2), - stable: true, - factory: Address::ZERO, - reserve_0: r0, - reserve_1: r1, - fee, - decimals_0: dec0, - decimals_1: dec1, - } - } - - #[test] - fn test_volatile_swap_basic() { - let pool = make_volatile_pool( - 1_000_000_000_000_000_000, // 1 ETH - 2_000_000_000, // 2000 USDC (6 decimals) - 30, // 0.3% - ); - - let amount_in = U256::from(100_000_000_000_000_000u128); // 0.1 ETH - let result = pool - .simulate_swap(pool.token_a, pool.token_b, amount_in) - .unwrap(); - - // With 0.3% fee and 1:2000 ratio, ~0.1 ETH should give ~181 USDC - assert!(result > U256::ZERO); - assert!(result < U256::from(200_000_000u128)); // Less than 200 USDC - } - - #[test] - fn test_volatile_swap_matches_uniswap_v2_formula() { - let pool = make_volatile_pool(1_000_000, 2_000_000, 30); - - let amount_in = U256::from(10_000u128); - let result = pool - .simulate_swap(pool.token_a, pool.token_b, amount_in) - .unwrap(); - - // Manual calculation: fee = 10000 * 9970 / 10000 = 9970 - // out = 9970 * 2000000 / (1000000 + 9970) = 19940000000 / 1009970 = 19743 - let expected = U256::from(19_743u128); - assert_eq!(result, expected); - } - - #[test] - fn test_volatile_swap_reverse_direction() { - let pool = make_volatile_pool(1_000_000, 2_000_000, 30); - - let amount_in = U256::from(10_000u128); - let result = pool - .simulate_swap(pool.token_b, pool.token_a, amount_in) - .unwrap(); - - // Swapping token_b for token_a with double reserves - assert!(result > U256::ZERO); - assert!(result < amount_in); // Should get less token_a since it's more valuable - } - - #[test] - fn test_volatile_swap_mut_updates_reserves() { - let mut pool = make_volatile_pool(1_000_000, 2_000_000, 30); - - let amount_in = U256::from(10_000u128); - let result = pool - .simulate_swap_mut(pool.token_a, pool.token_b, amount_in) - .unwrap(); - - assert_eq!(pool.reserve_0, 1_010_000); // Added 10k - assert_eq!(pool.reserve_1, 2_000_000 - result.to::()); // Removed output - } - - #[test] - fn test_volatile_zero_reserves_returns_zero() { - let pool = make_volatile_pool(0, 0, 30); - let result = pool - .simulate_swap(pool.token_a, pool.token_b, U256::from(1000u64)) - .unwrap(); - assert_eq!(result, U256::ZERO); - } - - #[test] - fn test_stable_swap_same_decimals() { - // Two stablecoins with same decimals (e.g. USDC/USDT both 6) - let pool = make_stable_pool( - 1_000_000_000, // 1000 USDC (6 dec) - 1_000_000_000, // 1000 USDT (6 dec) - 5, // 0.05% fee - 6, - 6, - ); - - let amount_in = U256::from(1_000_000u128); // 1 USDC - let result = pool - .simulate_swap(pool.token_a, pool.token_b, amount_in) - .unwrap(); - - // Stable swap near 1:1 should give close to 1 USDT - let result_u128: u128 = result.try_into().unwrap(); - assert!( - result_u128 > 990_000, - "Expected > 0.99 USDT, got {}", - result_u128 - ); - assert!( - result_u128 < 1_000_000, - "Expected < 1 USDT, got {}", - result_u128 - ); - } - - #[test] - fn test_stable_swap_different_decimals() { - // WETH (18 dec) / USDC (6 dec) stable pair — uncommon but tests normalization - let pool = make_stable_pool( - 1_000_000_000_000_000_000, // 1 WETH (18 dec) - 1_000_000, // 1 "unit" (6 dec) - 5, - 18, - 6, - ); - - let amount_in = U256::from(100_000_000_000_000_000u128); // 0.1 WETH - let result = pool - .simulate_swap(pool.token_a, pool.token_b, amount_in) - .unwrap(); - - // Should return some amount, not zero - assert!(result > U256::ZERO); - } - - #[test] - fn test_calculate_price() { - let pool = make_volatile_pool(1_000_000, 2_000_000, 30); - - let price_ab = pool.calculate_price(pool.token_a, pool.token_b).unwrap(); - assert!((price_ab - 2.0).abs() < 0.001); - - let price_ba = pool.calculate_price(pool.token_b, pool.token_a).unwrap(); - assert!((price_ba - 0.5).abs() < 0.001); - } -} diff --git a/src/stableswap_math.rs b/src/stableswap_math.rs deleted file mode 100644 index 26e500f..0000000 --- a/src/stableswap_math.rs +++ /dev/null @@ -1,243 +0,0 @@ -//! Shared StableSwap invariant math (Newton's method). -//! -//! Used by both Curve pools and Balancer V3 stable pools. All math operates -//! on 18-decimal scaled values internally; callers handle decimal conversion. - -use alloy_primitives::U256; - -pub const FEE_DENOMINATOR: u64 = 10_000_000_000; // 1e10 - -/// Compute the StableSwap invariant D using Newton's method. -/// -/// The invariant (for Curve's convention where A is already multiplied by n^(n-1)): -/// Ann * sum(x_i) + D = Ann * D + D^(n+1) / (n^n * prod(x_i)) -pub fn get_d(xp: &[U256], a: U256) -> U256 { - let n = U256::from(xp.len()); - let s: U256 = xp.iter().sum(); - if s.is_zero() { - return U256::ZERO; - } - - let ann = a * n; // A * n^n - - let mut d = s; - for _ in 0..256u32 { - // D_P = D^(n+1) / (n^n * prod(x_i)) - let mut d_p = d; - for x in xp { - // d_p = d_p * d / (x * n) — add 1 to avoid division by zero - d_p = d_p * d / (*x * n + U256::from(1)); - } - - let d_prev = d; - // D = (Ann * S + D_P * n) * D / ((Ann - 1) * D + (n + 1) * D_P) - let numerator = (ann * s + d_p * n) * d; - let denominator = (ann - U256::from(1)) * d + (n + U256::from(1)) * d_p; - if denominator.is_zero() { - break; - } - d = numerator / denominator; - - if d > d_prev { - if d - d_prev <= U256::from(1) { - return d; - } - } else if d_prev - d <= U256::from(1) { - return d; - } - } - d -} - -/// Compute the new balance of token j after changing token i, given the invariant. -pub fn get_y(xp: &[U256], a: U256, i: usize, j: usize, x_new_i: U256) -> U256 { - let n = U256::from(xp.len()); - let d = get_d(xp, a); - let ann = a * n; - - let mut s = U256::ZERO; - let mut c = d; - for (k, &xp_k) in xp.iter().enumerate() { - let x = if k == i { x_new_i } else { xp_k }; - if k != j { - s += x; - // No +1 here (unlike get_d) — matches Curve's get_y exactly - c = c * d / (x * n); - } - } - c = c * d / (ann * n); - - let b = s + d / ann; - - // Newton iteration to solve for y - let mut y = d; - for _ in 0..256u32 { - let y_prev = y; - // y = (y^2 + c) / (2*y + b - d) - let denom = U256::from(2) * y + b - d; - if denom.is_zero() { - break; - } - y = (y * y + c) / denom; - if y > y_prev { - if y - y_prev <= U256::from(1) { - return y; - } - } else if y_prev - y <= U256::from(1) { - return y; - } - } - y -} - -/// Simulate a stableswap exchange: returns the output amount after fees. -/// -/// - `reserves`: per-coin reserves in raw token units (native decimals) -/// - `precision_multipliers`: per-coin `10^(18 - decimals[i])` for scaling to 18 decimals -/// - `a`: amplification coefficient in Curve's internal format (_A() = A * A_PRECISION) -/// - `fee`: swap fee in parts-per-1e10 (e.g. 4_000_000 = 0.04%) -/// - `i`, `j`: coin indices (input and output) -/// - `dx`: amount in (native decimals) -pub fn stableswap_get_dy( - reserves: &[U256], - precision_multipliers: &[U256], - a: U256, - fee: U256, - i: usize, - j: usize, - dx: U256, -) -> Option { - let n = reserves.len(); - if i >= n || j >= n || i == j { - return None; - } - - // Scale reserves to 18 decimals - let xp: Vec = reserves - .iter() - .zip(precision_multipliers.iter()) - .map(|(r, m)| *r * *m) - .collect(); - - let dx_scaled = dx * precision_multipliers[i]; - let x_new = xp[i] + dx_scaled; - - let y_new = get_y(&xp, a, i, j, x_new); - let dy = xp[j].saturating_sub(y_new).saturating_sub(U256::from(1)); - - // Apply fee: dy_after_fee = dy * (1 - fee/FEE_DENOMINATOR) - let fee_amount = dy * fee / U256::from(FEE_DENOMINATOR); - let dy_after_fee = dy - fee_amount; - - // Unscale from 18 decimals - let pm_j = precision_multipliers[j]; - if pm_j.is_zero() { - return None; - } - Some(dy_after_fee / pm_j) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_stableswap_d_symmetric() { - // 2-pool with equal balances at 1e18 each, _A()=10000 (A=100) - let xp = vec![ - U256::from(1_000_000_000_000_000_000u128), - U256::from(1_000_000_000_000_000_000u128), - ]; - let d = get_d(&xp, U256::from(10_000)); - let d_val: u128 = d.try_into().unwrap(); - assert!(d_val > 1_999_000_000_000_000_000, "D too low: {}", d_val); - assert!(d_val < 2_001_000_000_000_000_000, "D too high: {}", d_val); - } - - #[test] - fn test_stableswap_get_dy_small() { - // Two stablecoins: USDC (6 dec) and USDT (6 dec), 1M each - let reserves = vec![ - U256::from(1_000_000_000_000u128), // 1M USDC - U256::from(1_000_000_000_000u128), // 1M USDT - ]; - let pm = vec![ - U256::from(1_000_000_000_000u128), // 10^12 (18-6) - U256::from(1_000_000_000_000u128), - ]; - let a = U256::from(20_000); // _A() = A(200) * 100 - let fee = U256::from(4_000_000u64); // 0.04% - - let dx = U256::from(1_000_000_000u128); // 1000 USDC - let dy = stableswap_get_dy(&reserves, &pm, a, fee, 0, 1, dx).unwrap(); - let dy_val: u128 = dy.try_into().unwrap(); - assert!(dy_val > 999_000_000, "expected ~999 USDT, got {}", dy_val); - assert!( - dy_val < 1_000_000_000, - "expected <1000 USDT, got {}", - dy_val - ); - } - - #[test] - fn test_stableswap_imbalanced() { - // Pool with 2M USDC but only 500K USDT - let reserves = vec![ - U256::from(2_000_000_000_000u128), // 2M USDC - U256::from(500_000_000_000u128), // 500K USDT - ]; - let pm = vec![ - U256::from(1_000_000_000_000u128), - U256::from(1_000_000_000_000u128), - ]; - let a = U256::from(20_000); - let fee = U256::from(4_000_000u64); - - let dx = U256::from(10_000_000_000u128); // 10K USDC - let dy = stableswap_get_dy(&reserves, &pm, a, fee, 0, 1, dx).unwrap(); - let dy_val: u128 = dy.try_into().unwrap(); - assert!( - dy_val < 10_000_000_000, - "should be less than input: {}", - dy_val - ); - assert!( - dy_val > 9_000_000_000, - "should still be reasonable: {}", - dy_val - ); - } - - #[test] - fn test_fee_conversion_balancer_to_curve() { - // Balancer V3: 0.05% = 5e14 (1e18 fixed point) - // Curve: 0.05% = 500_000 (parts per 1e10) - let balancer_fee = U256::from(500_000_000_000_000u64); // 5e14 - let curve_fee = balancer_fee / U256::from(100_000_000u64); // / 1e8 - assert_eq!(curve_fee, U256::from(5_000_000u64)); // 0.05% in 1e10 - } - - #[test] - fn test_18_decimal_pool_no_precision_scaling() { - // Two 18-decimal tokens (like WETH/rETH), precision_multiplier = 1 - let reserves = vec![ - U256::from(100_000_000_000_000_000_000u128), // 100 WETH - U256::from(100_000_000_000_000_000_000u128), // 100 rETH - ]; - let pm = vec![U256::from(1), U256::from(1)]; // 10^(18-18) = 1 - let a = U256::from(200_000); // High A for LST pair - let fee = U256::from(400_000u64); // 0.004% - - // Swap 1 WETH -> rETH - let dx = U256::from(1_000_000_000_000_000_000u128); // 1e18 - let dy = stableswap_get_dy(&reserves, &pm, a, fee, 0, 1, dx).unwrap(); - let dy_val: u128 = dy.try_into().unwrap(); - // Should be very close to 1e18 (near parity) - assert!( - dy_val > 999_900_000_000_000_000, - "expected ~1 rETH, got {}", - dy_val - ); - assert!(dy_val < 1_000_000_000_000_000_000, "should be < 1 rETH"); - } -} diff --git a/src/uniswap_v4_pool.rs b/src/uniswap_v4_pool.rs deleted file mode 100644 index 556530d..0000000 --- a/src/uniswap_v4_pool.rs +++ /dev/null @@ -1,68 +0,0 @@ -//! Uniswap V4 pool stub. -//! -//! Holds the PoolKey fields (fee, tickSpacing, hooks) that a downstream swap -//! encoder needs to address a Uniswap V4 pool. Full simulation is not yet -//! supported. - -use alloy_eips::BlockId; -use alloy_network::Network; -use alloy_primitives::{Address, B256, U256}; -use alloy_provider::Provider; -use alloy_rpc_types_eth::Log; -use amms::amms::{amm::AutomatedMarketMaker, balancer::BalancerError, error::AMMError}; - -#[derive(Debug, Clone)] -pub struct UniswapV4Pool { - pub address: Address, - pub currency0: Address, - pub currency1: Address, - pub fee: u32, - pub tick_spacing: i32, - pub hooks: Address, - pub tick: i32, - pub sqrt_price: U256, - pub liquidity: u128, -} - -impl AutomatedMarketMaker for UniswapV4Pool { - fn address(&self) -> Address { - self.address - } - - fn sync_events(&self) -> Vec { - vec![] - } - - fn sync(&mut self, _log: &Log) -> Result<(), AMMError> { - Ok(()) - } - - fn tokens(&self) -> Vec
{ - vec![self.currency0, self.currency1] - } - - fn calculate_price(&self, _base: Address, _quote: Address) -> Result { - Err(AMMError::from(BalancerError::InitializationError)) - } - - fn simulate_swap(&self, _base: Address, _quote: Address, _in: U256) -> Result { - Err(AMMError::from(BalancerError::InitializationError)) - } - - fn simulate_swap_mut( - &mut self, - _base: Address, - _quote: Address, - _in: U256, - ) -> Result { - Err(AMMError::from(BalancerError::InitializationError)) - } - - async fn init(self, _block: BlockId, _provider: P) -> Result - where - N: Network, - P: Provider + Clone, - { - Ok(self) - } -} diff --git a/tests/adapter_reactive.rs b/tests/adapter_reactive.rs index dd975ce..c7ce0a1 100644 --- a/tests/adapter_reactive.rs +++ b/tests/adapter_reactive.rs @@ -10,14 +10,14 @@ use alloy_rpc_types_eth::Log as RpcLog; use alloy_transport::mock::Asserter; use anyhow::Result; use evm_amm_state::adapters::storage::{ - V2_RESERVES_SLOT, V3_LIQUIDITY_SLOT, V3_SLOT0_SLOT, V3StorageLayout, + SolidlyStorageLayout, V2_RESERVES_SLOT, V3_LIQUIDITY_SLOT, V3_SLOT0_SLOT, V3StorageLayout, v3_tick_bitmap_storage_key_with_base, v3_tick_info_storage_keys_with_base, }; use evm_amm_state::adapters::{ AdapterRegistry, AmmAdapter, AmmReactiveHandler, BalancerV2Adapter, BalancerV2Metadata, ColdStartOutcome, ColdStartPolicy, DeferredWork, PoolKey, PoolRegistration, PoolStatus, - ProtocolMetadata, PurgeScope, StateUpdate, UniswapV2Adapter, UniswapV2Metadata, - UniswapV3Adapter, V3Metadata, + ProtocolMetadata, PurgeScope, SolidlyV2Adapter, SolidlyV2Metadata, StateUpdate, + UniswapV2Adapter, UniswapV2Metadata, UniswapV3Adapter, V3Metadata, }; use evm_fork_cache::cache::{EvmCache, StorageBatchFetchFn}; use evm_fork_cache::reactive::{ @@ -1789,3 +1789,117 @@ async fn v3_cold_start_missing_liquidity_is_still_ready() -> Result<()> { ); Ok(()) } + +// --- Solidly V2 reactive --- + +fn solidly_sync_topic() -> B256 { + keccak256("Sync(uint256,uint256)") +} + +fn solidly_registry(pool: Address, layout: SolidlyStorageLayout) -> AdapterRegistry { + let adapter = Arc::new(SolidlyV2Adapter::default()); + let mut registration = PoolRegistration::new(PoolKey::SolidlyV2(pool)) + .with_state_address(pool) + .with_metadata(ProtocolMetadata::SolidlyV2(SolidlyV2Metadata { + stable: Some(false), + storage_layout: Some(layout), + ..Default::default() + })); + let sources = adapter.event_sources(®istration); + registration = registration.with_event_sources(sources); + + let mut registry = AdapterRegistry::new(); + registry.register_adapter(adapter).unwrap(); + registry.register_pool(registration).unwrap(); + registry +} + +// A Solidly `Sync(uint256,uint256)` writes the two separate reserve slots exactly +// from the event payload (no fetch) — the V2-style exact event-sourcing, adapted +// to Solidly's unpacked reserve layout. +#[tokio::test] +async fn solidly_sync_writes_both_reserve_slots_through_runtime() -> Result<()> { + let pool = Address::repeat_byte(0x51); + let (r0_slot, r1_slot) = (U256::from(10_u64), U256::from(11_u64)); + let layout = + SolidlyStorageLayout::new(r0_slot, r1_slot, U256::from(12_u64), U256::from(13_u64)); + let reserve0 = U256::from(123_u64); + let reserve1 = U256::from(456_u64); + let log = rpc_log( + pool, + vec![solidly_sync_topic()], + abi_words([reserve0, reserve1]), + 11, + 0, + 0, + ); + + let mut cache = setup_cache().await?; + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + runtime.register_handler(Arc::new(AmmReactiveHandler::new(solidly_registry( + pool, layout, + ))))?; + + let report = runtime.ingest_batch( + &mut cache, + batch(vec![(ReactiveInput::Log(log), included_context(11, 0))]), + )?; + assert_eq!(report.applied.len(), 1, "the Sync must apply exactly once"); + + assert_eq!( + cache.cached_storage_value(pool, r0_slot), + Some(reserve0), + "reserve0 written exactly from the Sync payload" + ); + assert_eq!( + cache.cached_storage_value(pool, r1_slot), + Some(reserve1), + "reserve1 written exactly from the Sync payload" + ); + Ok(()) +} + +// A Solidly pool registered WITHOUT a storage layout (cold-start would be +// MissingMetadata) must not have a Sync silently mutate the cache — decode_event +// errors on the missing layout, so nothing is written. +#[tokio::test] +async fn solidly_sync_without_layout_does_not_mutate_cache() -> Result<()> { + let pool = Address::repeat_byte(0x52); + let adapter = Arc::new(SolidlyV2Adapter::default()); + let mut registration = PoolRegistration::new(PoolKey::SolidlyV2(pool)) + .with_state_address(pool) + .with_metadata(ProtocolMetadata::SolidlyV2(SolidlyV2Metadata { + stable: Some(false), + storage_layout: None, + ..Default::default() + })); + let sources = adapter.event_sources(®istration); + registration = registration.with_event_sources(sources); + let mut registry = AdapterRegistry::new(); + registry.register_adapter(adapter).unwrap(); + registry.register_pool(registration).unwrap(); + + let log = rpc_log( + pool, + vec![solidly_sync_topic()], + abi_words([U256::from(123_u64), U256::from(456_u64)]), + 11, + 0, + 0, + ); + let mut cache = setup_cache().await?; + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + runtime.register_handler(Arc::new(AmmReactiveHandler::new(registry)))?; + + // ingest must not panic, and a layout-less decode must write nothing. + let _ = runtime.ingest_batch( + &mut cache, + batch(vec![(ReactiveInput::Log(log), included_context(11, 0))]), + )?; + assert_eq!( + cache.cached_storage_value(pool, U256::from(10_u64)), + None, + "a layout-less Solidly Sync must not write any reserve slot" + ); + Ok(()) +} diff --git a/tests/adapter_swap_sim.rs b/tests/adapter_swap_sim.rs new file mode 100644 index 0000000..3ee870b --- /dev/null +++ b/tests/adapter_swap_sim.rs @@ -0,0 +1,679 @@ +//! WS2 swap-simulation harness tests (offline) + Balancer reactive integration. +//! +//! Every test runs FULLY OFFLINE over a mocked provider: a deterministic +//! mock-quote contract is installed at the quote target (fixture style of +//! `tests/fixtures/MockBalancerVault.sol`), its return value is derived from a +//! warmed "quote" slot, and `simulate_swap` is asserted to return it without any +//! RPC (`asserter.read_q().is_empty()`). A reverting target → `SimError::Reverted`. +//! +//! The Balancer reactive test cold-starts a combined mock vault, ingests a +//! `Swap` log through the reactive runtime, and asserts the discovered balance +//! slots are refreshed so a subsequent `simulate_swap` reflects the change. + +use std::collections::HashMap; +use std::sync::Arc; + +use alloy_eips::BlockId; +use alloy_network::Ethereum; +use alloy_primitives::{Address, B256, Bytes, Log as PrimitiveLog, U256, hex, keccak256}; +use alloy_provider::{RootProvider, network::AnyNetwork}; +use alloy_rpc_client::RpcClient; +use alloy_rpc_types_eth::Log as RpcLog; +use alloy_transport::mock::Asserter; +use anyhow::{Result, anyhow}; + +use evm_amm_state::adapters::storage::SolidlyStorageLayout; +use evm_amm_state::adapters::{ + AdapterRegistry, AmmAdapter, AmmReactiveHandler, BalancerV2Adapter, BalancerV2Metadata, + ColdStartOutcome, ColdStartPolicy, PoolKey, PoolRegistration, PoolStatus, ProtocolMetadata, + SimConfig, SimError, SolidlyV2Adapter, SolidlyV2Metadata, UniswapV2Adapter, UniswapV2Metadata, + UniswapV3Adapter, V3Metadata, +}; +use evm_fork_cache::cache::{EvmCache, StorageBatchFetchFn}; +use evm_fork_cache::reactive::{ + BlockRef, ChainStatus, InputSource, ReactiveConfig, ReactiveContext, ReactiveInput, + ReactiveInputBatch, ReactiveInputRecord, ReactiveRuntime, +}; +use revm::state::{AccountInfo, Bytecode}; + +// --- offline cache scaffolding (mirrors cold_start_adoption.rs) --- + +async fn setup_cache_with_asserter() -> Result<(EvmCache, Asserter)> { + let asserter = Asserter::new(); + let client = RpcClient::mocked(asserter.clone()); + let provider = RootProvider::::new(client); + Ok((EvmCache::new(Arc::new(provider)).await, asserter)) +} + +fn install_default_account(cache: &mut EvmCache, addr: Address) { + cache + .db_mut() + .insert_account_info(addr, AccountInfo::default()); +} + +/// Install raw runtime bytecode (a compiled mock fixture) at `addr`. +fn install_runtime(cache: &mut EvmCache, addr: Address, runtime: &str) { + let code = Bytecode::new_raw(Bytes::from( + hex::decode(runtime.trim()).expect("valid mock runtime hex"), + )); + let code_hash = code.hash_slow(); + cache.db_mut().insert_account_info( + addr, + AccountInfo { + balance: U256::ZERO, + nonce: 1, + code: Some(code), + code_hash, + account_id: None, + }, + ); +} + +/// A fetcher returning `Err` for `fail` slots and `Ok(value-or-ZERO)` otherwise. +fn fetcher( + values: HashMap<(Address, U256), U256>, + fail: Vec<(Address, U256)>, +) -> StorageBatchFetchFn { + let fail: std::collections::HashSet<(Address, U256)> = fail.into_iter().collect(); + Arc::new( + move |requests: Vec<(Address, U256)>, _block: Option| { + requests + .into_iter() + .map(|(address, slot)| { + if fail.contains(&(address, slot)) { + (address, slot, Err(anyhow!("archive miss"))) + } else { + ( + address, + slot, + Ok(values.get(&(address, slot)).copied().unwrap_or_default()), + ) + } + }) + .collect() + }, + ) +} + +fn token_slot_word(addr: Address) -> U256 { + U256::from_be_slice(addr.as_slice()) +} + +const REVERT_RUNTIME: &str = include_str!("fixtures/mock_balancer_vault_revert_runtime.hex"); + +// --- Uniswap V2 offline harness --- + +#[tokio::test(flavor = "multi_thread")] +async fn v2_simulate_swap_returns_router_quote_offline() -> Result<()> { + let router = Address::repeat_byte(0xa1); + let token_in = Address::repeat_byte(0x01); + let token_out = Address::repeat_byte(0x02); + let expected_out = U256::from(4_242_u64); + + let (mut cache, asserter) = setup_cache_with_asserter().await?; + install_default_account(&mut cache, Address::ZERO); + install_runtime( + &mut cache, + router, + include_str!("fixtures/mock_v2_router_runtime.hex"), + ); + // The mock router returns `sload(0)` as the output amount; seed it. + cache + .db_mut() + .insert_account_storage(router, U256::ZERO, expected_out)?; + + let adapter = UniswapV2Adapter::default(); + let registration = PoolRegistration::new(PoolKey::UniswapV2(Address::repeat_byte(0x11))) + .with_metadata(ProtocolMetadata::UniswapV2(UniswapV2Metadata { + token0: Some(token_in), + token1: Some(token_out), + fee_bps: Some(30), + })); + let config = SimConfig::default().with_v2_router(router); + + let quote = adapter + .simulate_swap( + ®istration, + &mut cache, + token_in, + token_out, + U256::from(1_000_u64), + &config, + ) + .expect("v2 quote should succeed"); + + assert_eq!(quote.amount_out, expected_out); + assert!( + asserter.read_q().is_empty(), + "swap sim must be fully offline (no RPC)" + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn v2_simulate_swap_reverting_target_is_reverted() -> Result<()> { + let router = Address::repeat_byte(0xa2); + let token_in = Address::repeat_byte(0x01); + let token_out = Address::repeat_byte(0x02); + + let (mut cache, asserter) = setup_cache_with_asserter().await?; + install_default_account(&mut cache, Address::ZERO); + install_runtime(&mut cache, router, REVERT_RUNTIME); + + let adapter = UniswapV2Adapter::default(); + let registration = PoolRegistration::new(PoolKey::UniswapV2(Address::repeat_byte(0x11))); + let config = SimConfig::default().with_v2_router(router); + + let err = adapter + .simulate_swap( + ®istration, + &mut cache, + token_in, + token_out, + U256::from(1_000_u64), + &config, + ) + .expect_err("reverting router must error"); + assert_eq!(err, SimError::Reverted); + assert!(asserter.read_q().is_empty(), "must be fully offline"); + Ok(()) +} + +// --- Uniswap V3 offline harness --- + +#[tokio::test(flavor = "multi_thread")] +async fn v3_simulate_swap_returns_quoter_amount_offline() -> Result<()> { + let quoter = Address::repeat_byte(0xb1); + let token_in = Address::repeat_byte(0x01); + let token_out = Address::repeat_byte(0x02); + let expected_out = U256::from(9_999_u64); + + let (mut cache, asserter) = setup_cache_with_asserter().await?; + install_default_account(&mut cache, Address::ZERO); + install_runtime( + &mut cache, + quoter, + include_str!("fixtures/mock_v3_quoter_runtime.hex"), + ); + cache + .db_mut() + .insert_account_storage(quoter, U256::ZERO, expected_out)?; + + let adapter = UniswapV3Adapter::default(); + let registration = PoolRegistration::new(PoolKey::UniswapV3(Address::repeat_byte(0x21))) + .with_metadata(ProtocolMetadata::UniswapV3(V3Metadata { + token0: Some(token_in), + token1: Some(token_out), + fee: Some(500), + tick_spacing: Some(10), + ..Default::default() + })); + let config = SimConfig::default().with_v3_quoter(quoter); + + let quote = adapter + .simulate_swap( + ®istration, + &mut cache, + token_in, + token_out, + U256::from(1_000_u64), + &config, + ) + .expect("v3 quote should succeed"); + + assert_eq!(quote.amount_out, expected_out); + assert!(asserter.read_q().is_empty(), "must be fully offline"); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn v3_simulate_swap_reverting_target_is_reverted() -> Result<()> { + let quoter = Address::repeat_byte(0xb2); + let token_in = Address::repeat_byte(0x01); + let token_out = Address::repeat_byte(0x02); + + let (mut cache, asserter) = setup_cache_with_asserter().await?; + install_default_account(&mut cache, Address::ZERO); + install_runtime(&mut cache, quoter, REVERT_RUNTIME); + + let adapter = UniswapV3Adapter::default(); + let registration = PoolRegistration::new(PoolKey::UniswapV3(Address::repeat_byte(0x21))) + .with_metadata(ProtocolMetadata::UniswapV3(V3Metadata { + fee: Some(500), + ..Default::default() + })); + let config = SimConfig::default().with_v3_quoter(quoter); + + let err = adapter + .simulate_swap( + ®istration, + &mut cache, + token_in, + token_out, + U256::from(1_000_u64), + &config, + ) + .expect_err("reverting quoter must error"); + assert_eq!(err, SimError::Reverted); + assert!(asserter.read_q().is_empty(), "must be fully offline"); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn v3_simulate_swap_missing_fee_is_missing_metadata() -> Result<()> { + let quoter = Address::repeat_byte(0xb3); + let (mut cache, _asserter) = setup_cache_with_asserter().await?; + install_default_account(&mut cache, Address::ZERO); + install_runtime( + &mut cache, + quoter, + include_str!("fixtures/mock_v3_quoter_runtime.hex"), + ); + + let adapter = UniswapV3Adapter::default(); + // No `fee` in metadata -> the quote cannot be built. + let registration = PoolRegistration::new(PoolKey::UniswapV3(Address::repeat_byte(0x21))) + .with_metadata(ProtocolMetadata::UniswapV3(V3Metadata::default())); + let config = SimConfig::default().with_v3_quoter(quoter); + + let err = adapter + .simulate_swap( + ®istration, + &mut cache, + Address::repeat_byte(0x01), + Address::repeat_byte(0x02), + U256::from(1_000_u64), + &config, + ) + .expect_err("missing fee must error"); + assert_eq!(err, SimError::MissingMetadata("V3 fee")); + Ok(()) +} + +// --- Balancer V2 offline harness --- + +/// Install the combined `MockBalancerVaultQuote` stub and seed slots 0..=4. +/// `queryBatchSwap` returns balance0 (slot 2) as the negated tokenOut delta. +fn install_balancer_quote_vault( + cache: &mut EvmCache, + vault: Address, + balance0: U256, +) -> Result<()> { + install_runtime( + cache, + vault, + include_str!("fixtures/mock_balancer_vault_quote_runtime.hex"), + ); + let token0 = Address::repeat_byte(0xc0); + let token1 = Address::repeat_byte(0xc1); + cache + .db_mut() + .insert_account_storage(vault, U256::from(0), token_slot_word(token0))?; + cache + .db_mut() + .insert_account_storage(vault, U256::from(1), token_slot_word(token1))?; + cache + .db_mut() + .insert_account_storage(vault, U256::from(2), balance0)?; + cache + .db_mut() + .insert_account_storage(vault, U256::from(3), U256::from(2_u64))?; + cache + .db_mut() + .insert_account_storage(vault, U256::from(4), U256::from(7_u64))?; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn balancer_simulate_swap_returns_vault_quote_offline() -> Result<()> { + let vault = Address::repeat_byte(0xd1); + let mut pid = [0u8; 32]; + pid[..20].fill(0x33); + let pool_id = B256::from(pid); + let token_in = Address::repeat_byte(0xc0); + let token_out = Address::repeat_byte(0xc1); + let expected_out = U256::from(5_000_u64); + + let (mut cache, asserter) = setup_cache_with_asserter().await?; + install_default_account(&mut cache, Address::ZERO); + install_balancer_quote_vault(&mut cache, vault, expected_out)?; + + let adapter = BalancerV2Adapter::default(); + let registration = PoolRegistration::new(PoolKey::BalancerV2(pool_id)) + .with_state_address(vault) + .with_metadata(ProtocolMetadata::BalancerV2(BalancerV2Metadata { + vault: Some(vault), + ..Default::default() + })); + let config = SimConfig::default(); + + let quote = adapter + .simulate_swap( + ®istration, + &mut cache, + token_in, + token_out, + U256::from(1_000_u64), + &config, + ) + .expect("balancer quote should succeed"); + + assert_eq!(quote.amount_out, expected_out); + assert!(asserter.read_q().is_empty(), "must be fully offline"); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn balancer_simulate_swap_reverting_vault_is_reverted() -> Result<()> { + let vault = Address::repeat_byte(0xd2); + let pool_id = B256::repeat_byte(0x34); + + let (mut cache, asserter) = setup_cache_with_asserter().await?; + install_default_account(&mut cache, Address::ZERO); + install_runtime(&mut cache, vault, REVERT_RUNTIME); + + let adapter = BalancerV2Adapter::default(); + let registration = PoolRegistration::new(PoolKey::BalancerV2(pool_id)) + .with_state_address(vault) + .with_metadata(ProtocolMetadata::BalancerV2(BalancerV2Metadata { + vault: Some(vault), + ..Default::default() + })); + let config = SimConfig::default(); + + let err = adapter + .simulate_swap( + ®istration, + &mut cache, + Address::repeat_byte(0xc0), + Address::repeat_byte(0xc1), + U256::from(1_000_u64), + &config, + ) + .expect_err("reverting vault must error"); + assert_eq!(err, SimError::Reverted); + assert!(asserter.read_q().is_empty(), "must be fully offline"); + Ok(()) +} + +// --- Balancer V2 reactive integration: cold-start -> Swap refresh -> re-quote --- + +fn balancer_swap_topic() -> B256 { + keccak256("Swap(bytes32,address,address,uint256,uint256)") +} + +fn topic_address(address: Address) -> B256 { + let mut bytes = [0u8; 32]; + bytes[12..].copy_from_slice(address.as_slice()); + B256::from(bytes) +} + +fn word(value: U256) -> Vec { + value.to_be_bytes::<32>().to_vec() +} + +fn rpc_log(address: Address, topics: Vec, data: Vec, block_number: u64) -> RpcLog { + RpcLog { + inner: PrimitiveLog::new_unchecked(address, topics, Bytes::from(data)), + block_hash: Some(B256::repeat_byte(block_number as u8)), + block_number: Some(block_number), + block_timestamp: Some(1_700_000_000 + block_number), + transaction_hash: Some(B256::repeat_byte(0x01)), + transaction_index: Some(0), + log_index: Some(0), + removed: false, + } +} + +fn included_context(block_number: u64) -> ReactiveContext { + let block = BlockRef { + number: block_number, + hash: B256::repeat_byte(block_number as u8), + parent_hash: Some(B256::repeat_byte(block_number.saturating_sub(1) as u8)), + timestamp: Some(1_700_000_000 + block_number), + }; + ReactiveContext { + chain_id: Some(1), + source: InputSource::Synthetic, + chain_status: ChainStatus::Included { + block: block.clone(), + confirmations: 0, + }, + block: Some(block), + transaction_index: Some(0), + log_index: Some(0), + } +} + +fn batch(input: ReactiveInput, context: ReactiveContext) -> ReactiveInputBatch { + ReactiveInputBatch::new(vec![ReactiveInputRecord::new(input, context)]) +} + +/// Cold-start a combined mock vault, ingest a `Swap`, and assert the discovered +/// balance slot is refreshed so a subsequent `simulate_swap` reflects the change. +/// +/// Before this work the `Swap` produced no cache mutation (the old routing-only +/// decode), so the re-quote would still see the stale balance. +#[tokio::test(flavor = "multi_thread")] +async fn balancer_reactive_swap_refreshes_balances_for_resim() -> Result<()> { + let vault = Address::repeat_byte(0xe1); + let mut pid = [0u8; 32]; + pid[..20].fill(0x44); + let pool_id = B256::from(pid); + let token0 = Address::repeat_byte(0xc0); + let token1 = Address::repeat_byte(0xc1); + + let stale_balance0 = U256::from(1_000_u64); + let fresh_balance0 = U256::from(7_777_u64); + + let (mut cache, asserter) = setup_cache_with_asserter().await?; + install_default_account(&mut cache, Address::ZERO); + // Seed the vault with the STALE balance0 at slot 2. + install_balancer_quote_vault(&mut cache, vault, stale_balance0)?; + // The verify/resync fetcher returns the discovered slots; balance slot 2 + // now returns the FRESH balance (simulating the post-swap on-chain state). + cache.set_storage_batch_fetcher(fetcher( + HashMap::from([ + ((vault, U256::from(0)), token_slot_word(token0)), + ((vault, U256::from(1)), token_slot_word(token1)), + ((vault, U256::from(2)), fresh_balance0), + ((vault, U256::from(3)), U256::from(2_u64)), + ((vault, U256::from(4)), U256::from(7_u64)), + ]), + Vec::new(), + )); + + // 1. Cold-start: discover -> verify warms the balance slots and persists + // them on the metadata. + let mut cold_registry = AdapterRegistry::new(); + cold_registry + .register_adapter(Arc::new(BalancerV2Adapter::default())) + .unwrap(); + let mut registration = PoolRegistration::new(PoolKey::BalancerV2(pool_id)) + .with_state_address(vault) + .with_metadata(ProtocolMetadata::BalancerV2(BalancerV2Metadata { + vault: Some(vault), + ..Default::default() + })); + let outcome = + cold_registry.cold_start(&mut registration, &mut cache, ColdStartPolicy::Eager)?; + assert!( + matches!(outcome, ColdStartOutcome::Ready(_)), + "cold start should be Ready, got {outcome:?}" + ); + assert_eq!(registration.status, PoolStatus::Ready); + + // The cold-start verify round already refreshed slot 2 to the fetcher's + // value (it returns the fresh balance for every read), so re-seed the STALE + // value to prove the *reactive Swap* is what refreshes it below. + cache + .db_mut() + .insert_account_storage(vault, U256::from(2), stale_balance0)?; + assert_eq!( + cache.cached_storage_value(vault, U256::from(2)), + Some(stale_balance0), + "re-seeded stale balance0 before the reactive swap" + ); + + // The discovered balance slots must be persisted on the metadata so the + // reactive decode can reach them. + let balance_slots = match ®istration.metadata { + ProtocolMetadata::BalancerV2(m) => { + assert!( + m.balance_slots.contains(&U256::from(2)), + "balance slot 2 must be persisted, got {:?}", + m.balance_slots + ); + m.balance_slots.clone() + } + other => panic!("expected BalancerV2 metadata, got {other:?}"), + }; + assert!(!balance_slots.is_empty()); + + // 2. Ingest a Swap log through the reactive runtime; the adapter emits a + // VerifySlots refresh that the runtime lowers into an executed resync, + // re-reading slot 2 to the fresh balance. + let mut registry = AdapterRegistry::new(); + registry + .register_adapter(Arc::new(BalancerV2Adapter::default())) + .unwrap(); + let adapter = BalancerV2Adapter::default(); + let sources = adapter.event_sources(®istration); + registration = registration.with_event_sources(sources); + registry.register_pool(registration.clone()).unwrap(); + + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + runtime.register_handler(Arc::new(AmmReactiveHandler::new(registry)))?; + + let mut data = word(U256::from(1_000_u64)); // amountIn + data.extend(word(U256::from(900_u64))); // amountOut + let log = rpc_log( + vault, + vec![ + balancer_swap_topic(), + pool_id, + topic_address(token0), + topic_address(token1), + ], + data, + 100, + ); + + let report = runtime.ingest_batch_with_resync( + &mut cache, + batch(ReactiveInput::Log(log), included_context(100)), + )?; + assert_eq!(report.applied.len(), 1, "swap should produce one effect"); + + // The reactive resync refreshed the discovered balance slot to the fresh value. + assert_eq!( + cache.cached_storage_value(vault, U256::from(2)), + Some(fresh_balance0), + "the reactive Swap must refresh balance slot 2 to the fresh value" + ); + + // 3. A subsequent simulate_swap now reflects the refreshed balance. + let config = SimConfig::default(); + let quote = adapter + .simulate_swap( + ®istration, + &mut cache, + token0, + token1, + U256::from(1_000_u64), + &config, + ) + .expect("post-swap quote should succeed"); + assert_eq!( + quote.amount_out, fresh_balance0, + "the re-simulated quote must reflect the refreshed balance" + ); + + assert!(asserter.read_q().is_empty(), "must be fully offline"); + Ok(()) +} + +// --- Solidly V2 offline harness --- + +// Solidly's simulate_swap executes the POOL's own getAmountOut(amountIn, tokenIn) +// against the warmed pool — no router/quoter, no SimConfig target, no layout. +#[tokio::test(flavor = "multi_thread")] +async fn solidly_simulate_swap_returns_pool_quote_offline() -> Result<()> { + let pool = Address::repeat_byte(0x71); + let token_in = Address::repeat_byte(0x01); + let token_out = Address::repeat_byte(0x02); + let expected_out = U256::from(7_777_u64); + + let (mut cache, asserter) = setup_cache_with_asserter().await?; + install_default_account(&mut cache, Address::ZERO); + // Mock pool: getAmountOut returns sload(0); seed it. + install_runtime( + &mut cache, + pool, + include_str!("fixtures/mock_solidly_pool_runtime.hex"), + ); + cache + .db_mut() + .insert_account_storage(pool, U256::ZERO, expected_out)?; + + let adapter = SolidlyV2Adapter::default(); + let registration = PoolRegistration::new(PoolKey::SolidlyV2(pool)) + .with_state_address(pool) + .with_metadata(ProtocolMetadata::SolidlyV2(SolidlyV2Metadata { + token0: Some(token_in), + token1: Some(token_out), + stable: Some(false), + storage_layout: Some(SolidlyStorageLayout::new( + U256::from(10_u64), + U256::from(11_u64), + U256::from(12_u64), + U256::from(13_u64), + )), + })); + + let quote = adapter + .simulate_swap( + ®istration, + &mut cache, + token_in, + token_out, + U256::from(1_000_u64), + &SimConfig::default(), + ) + .expect("solidly quote should succeed"); + + assert_eq!(quote.amount_out, expected_out); + assert!( + asserter.read_q().is_empty(), + "swap sim must be fully offline (no RPC)" + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn solidly_simulate_swap_reverting_pool_is_reverted() -> Result<()> { + let pool = Address::repeat_byte(0x72); + + let (mut cache, asserter) = setup_cache_with_asserter().await?; + install_default_account(&mut cache, Address::ZERO); + install_runtime(&mut cache, pool, REVERT_RUNTIME); + + let adapter = SolidlyV2Adapter::default(); + let registration = PoolRegistration::new(PoolKey::SolidlyV2(pool)) + .with_state_address(pool) + .with_metadata(ProtocolMetadata::SolidlyV2(SolidlyV2Metadata::default())); + + let err = adapter + .simulate_swap( + ®istration, + &mut cache, + Address::repeat_byte(0x01), + Address::repeat_byte(0x02), + U256::from(1_000_u64), + &SimConfig::default(), + ) + .expect_err("reverting pool must error"); + assert_eq!(err, SimError::Reverted); + assert!(asserter.read_q().is_empty(), "must be fully offline"); + Ok(()) +} diff --git a/tests/adapter_swap_sim_rpc.rs b/tests/adapter_swap_sim_rpc.rs new file mode 100644 index 0000000..54a3a7a --- /dev/null +++ b/tests/adapter_swap_sim_rpc.rs @@ -0,0 +1,440 @@ +//! WS2 RPC parity test (env-gated, `#[ignore]`) — MANAGER RUNS THIS. +//! +//! Given `E2E_RPC_URL` (an archive node), this forks mainnet at a PINNED block, +//! cold-starts a known pool, runs `simulate_swap`, and asserts the result equals +//! the SAME quote executed via the provider's `eth_call` at the same block (the +//! on-chain ground truth). An exact match is expected: identical bytecode + +//! identical state at the pinned height. +//! +//! Not run in CI (no network). Build-checked via +//! `cargo build --tests --test adapter_swap_sim_rpc`. To run: +//! ```text +//! E2E_RPC_URL= cargo test --test adapter_swap_sim_rpc -- --ignored +//! ``` +//! +//! ## Pinned block + pool addresses +//! +//! - Fork block: **20_000_000** (Ethereum mainnet, 2024-05-21). All pools below +//! were deployed and active well before this height. +//! - Uniswap V3: USDC/WETH 0.05% pool `0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640` +//! (fee 500, tick spacing 10). QuoterV2 `0x61fFE014bA17989E743c5F6cB21bF9697530B21e`. +//! - Uniswap V2: USDC/WETH pair `0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc`. +//! Router02 `0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D`. +//! - Balancer V2: 80BAL/20WETH weighted pool, poolId +//! `0x5c6ee304399dbdb9c8ef030ab642b10820db8f56000200000000000000000014`, +//! vault `0xBA12222222228d8Ba445958a75a0704d566BF2C8`. +//! - Token amounts use a 1e6 USDC / 1e18 WETH-scaled input as noted per test. +//! +//! The Solidly V2 parity test forks **Base** (not Ethereum), block +//! `47_700_000`, against the Aerodrome WETH/USDC volatile pool +//! `0xcDAC0d6c6C59727a65F871236188350531885C43`. It uses a Base RPC url — +//! `E2E_BASE_RPC_URL`, or `E2E_RPC_URL` with the Alchemy `eth-mainnet` host +//! swapped to `base-mainnet`: +//! ```text +//! E2E_RPC_URL= cargo test --test adapter_swap_sim_rpc -- --ignored +//! ``` + +use std::sync::Arc; + +use alloy_eips::{BlockId, BlockNumberOrTag}; +use alloy_network::{AnyNetwork, TransactionBuilder}; +use alloy_primitives::{Address, B256, Bytes, U256, address, b256}; +use alloy_provider::{Provider, RootProvider}; +use alloy_rpc_types_eth::TransactionRequest; +use alloy_sol_types::SolCall; +use anyhow::{Context, Result, anyhow}; + +use evm_amm_state::adapters::sim::{ + BatchSwapStep, FundManagement, QuoteExactInputSingleParams, getAmountOutCall, + getAmountsOutCall, queryBatchSwapCall, quoteExactInputSingleCall, +}; +use evm_amm_state::adapters::storage::SolidlyStorageLayout; +use evm_amm_state::adapters::{ + AdapterRegistry, AmmAdapter, BalancerV2Adapter, BalancerV2Metadata, ColdStartPolicy, PoolKey, + PoolRegistration, ProtocolMetadata, SimConfig, SolidlyV2Adapter, SolidlyV2Metadata, + UniswapV2Adapter, UniswapV2Metadata, UniswapV3Adapter, V3Metadata, +}; +use evm_fork_cache::cache::EvmCache; + +alloy_sol_types::sol! { + /// Solidly pool reserve view fns — used only to cross-check the empirical + /// storage-slot layout against the live pool's authoritative reserves. + function reserve0() returns (uint256); + function reserve1() returns (uint256); +} + +const FORK_BLOCK: u64 = 20_000_000; + +const USDC: Address = address!("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"); +const WETH: Address = address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"); + +const V3_QUOTER_V2: Address = address!("61fFE014bA17989E743c5F6cB21bF9697530B21e"); +const V3_USDC_WETH_005: Address = address!("88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640"); + +const V2_ROUTER_02: Address = address!("7a250d5630B4cF539739dF2C5dAcb4c659F2488D"); +const V2_USDC_WETH_PAIR: Address = address!("B4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc"); + +const BALANCER_VAULT: Address = address!("BA12222222228d8Ba445958a75a0704d566BF2C8"); +const BALANCER_BAL_WETH_POOL_ID: B256 = + b256!("5c6ee304399dbdb9c8ef030ab642b10820db8f56000200000000000000000014"); +const BAL: Address = address!("ba100000625a3754423978a60c9317c58a424e3D"); + +// --- Solidly V2 (Aerodrome on Base) --- +// +// Base mainnet fork block. Aerodrome WETH/USDC volatile pool, discovered from +// the PoolFactory and verified at this height (see the empirical layout scan +// baked into `AERO_*_SLOT` below). +const SOLIDLY_FORK_BLOCK: u64 = 47_700_000; +const BASE_WETH: Address = address!("4200000000000000000000000000000000000006"); +const BASE_USDC: Address = address!("833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"); +// factory.getPool(WETH, USDC, stable=false) at the fork block. +const AERODROME_WETH_USDC: Address = address!("cDAC0d6c6C59727a65F871236188350531885C43"); +// Storage layout verified empirically at the fork block by matching +// eth_getStorageAt against the pool's token0()/token1()/reserve0()/reserve1(): +// token0 -> slot 13, token1 -> slot 14, reserve0 -> slot 20, reserve1 -> slot 21. +const AERO_RESERVE0_SLOT: u64 = 20; +const AERO_RESERVE1_SLOT: u64 = 21; +const AERO_TOKEN0_SLOT: u64 = 13; +const AERO_TOKEN1_SLOT: u64 = 14; + +fn rpc_url() -> Option { + std::env::var("E2E_RPC_URL").ok() +} + +/// Base RPC url for the Solidly parity test: an explicit `E2E_BASE_RPC_URL` if +/// set, otherwise derived from `E2E_RPC_URL` by swapping the Alchemy +/// `eth-mainnet` host segment for `base-mainnet` (Aerodrome lives on Base, not +/// Ethereum mainnet). Returns `None` if neither is available. +fn base_rpc_url() -> Option { + if let Ok(url) = std::env::var("E2E_BASE_RPC_URL") { + return Some(url); + } + std::env::var("E2E_RPC_URL") + .ok() + .map(|url| url.replace("eth-mainnet", "base-mainnet")) +} + +async fn fork_cache(url: &str, block: u64) -> Result { + let provider = RootProvider::::connect(url) + .await + .context("connect RPC url")?; + Ok(EvmCache::at_block( + Arc::new(provider), + BlockId::Number(BlockNumberOrTag::Number(block)), + ) + .await) +} + +/// Execute `calldata` against `target` via the provider's `eth_call` at the +/// pinned `block` — the on-chain ground truth. +async fn eth_call_at(url: &str, target: Address, calldata: Bytes, block: u64) -> Result { + let provider = RootProvider::::connect(url).await?; + let tx = TransactionRequest::default() + .with_to(target) + .with_input(calldata); + let out = provider + .call(tx.into()) + .block(BlockId::Number(BlockNumberOrTag::Number(block))) + .await + .context("eth_call at fork block")?; + Ok(out) +} + +#[tokio::test(flavor = "multi_thread")] +#[ignore = "requires E2E_RPC_URL archive node; run with --ignored"] +async fn v3_simulate_swap_matches_eth_call() -> Result<()> { + let Some(url) = rpc_url() else { + eprintln!("E2E_RPC_URL unset; skipping"); + return Ok(()); + }; + + // 1 USDC in (6 decimals). + let amount_in = U256::from(1_000_000_u64); + + let mut cache = fork_cache(&url, FORK_BLOCK).await?; + let registry = { + let mut r = AdapterRegistry::new(); + r.register_adapter(Arc::new(UniswapV3Adapter::default()))?; + r + }; + let mut registration = PoolRegistration::new(PoolKey::UniswapV3(V3_USDC_WETH_005)) + .with_state_address(V3_USDC_WETH_005) + .with_metadata(ProtocolMetadata::UniswapV3(V3Metadata { + token0: Some(USDC), + token1: Some(WETH), + fee: Some(500), + tick_spacing: Some(10), + storage_layout: Some(evm_amm_state::adapters::storage::V3StorageLayout::uniswap( + 10, + )), + })); + registry.cold_start(&mut registration, &mut cache, ColdStartPolicy::Eager)?; + + let config = SimConfig::default().with_v3_quoter(V3_QUOTER_V2); + let adapter = UniswapV3Adapter::default(); + let sim = adapter + .simulate_swap(®istration, &mut cache, USDC, WETH, amount_in, &config) + .map_err(|e| anyhow!("v3 sim failed: {e}"))?; + + // Ground truth: the SAME QuoterV2 call via eth_call at the fork block. + let calldata = Bytes::from( + quoteExactInputSingleCall { + params: QuoteExactInputSingleParams { + tokenIn: USDC, + tokenOut: WETH, + amountIn: amount_in, + fee: alloy_primitives::aliases::U24::from(500u32), + sqrtPriceLimitX96: U256::ZERO.to(), + }, + } + .abi_encode(), + ); + let out = eth_call_at(&url, V3_QUOTER_V2, calldata, FORK_BLOCK).await?; + let truth = quoteExactInputSingleCall::abi_decode_returns_validate(&out)?; + + assert_eq!( + sim.amount_out, truth.amountOut, + "V3 sim amount_out must match eth_call QuoterV2" + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +#[ignore = "requires E2E_RPC_URL archive node; run with --ignored"] +async fn v2_simulate_swap_matches_eth_call() -> Result<()> { + let Some(url) = rpc_url() else { + eprintln!("E2E_RPC_URL unset; skipping"); + return Ok(()); + }; + + let amount_in = U256::from(1_000_000_u64); // 1 USDC + + let mut cache = fork_cache(&url, FORK_BLOCK).await?; + let registry = { + let mut r = AdapterRegistry::new(); + r.register_adapter(Arc::new(UniswapV2Adapter::default()))?; + r + }; + let mut registration = PoolRegistration::new(PoolKey::UniswapV2(V2_USDC_WETH_PAIR)) + .with_state_address(V2_USDC_WETH_PAIR) + .with_metadata(ProtocolMetadata::UniswapV2(UniswapV2Metadata { + token0: Some(USDC), + token1: Some(WETH), + fee_bps: Some(30), + })); + registry.cold_start(&mut registration, &mut cache, ColdStartPolicy::Eager)?; + + let config = SimConfig::default().with_v2_router(V2_ROUTER_02); + let adapter = UniswapV2Adapter::default(); + let sim = adapter + .simulate_swap(®istration, &mut cache, USDC, WETH, amount_in, &config) + .map_err(|e| anyhow!("v2 sim failed: {e}"))?; + + let calldata = Bytes::from( + getAmountsOutCall { + amountIn: amount_in, + path: vec![USDC, WETH], + } + .abi_encode(), + ); + let out = eth_call_at(&url, V2_ROUTER_02, calldata, FORK_BLOCK).await?; + let amounts = getAmountsOutCall::abi_decode_returns_validate(&out)?; + + assert_eq!( + sim.amount_out, + *amounts.last().expect("non-empty amounts"), + "V2 sim amount_out must match eth_call Router02 getAmountsOut" + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +#[ignore = "requires E2E_RPC_URL archive node; run with --ignored"] +async fn balancer_simulate_swap_matches_eth_call() -> Result<()> { + let Some(url) = rpc_url() else { + eprintln!("E2E_RPC_URL unset; skipping"); + return Ok(()); + }; + + let amount_in = U256::from(1_000_000_000_000_000_000_u64); // 1 BAL (18 decimals) + + let mut cache = fork_cache(&url, FORK_BLOCK).await?; + let registry = { + let mut r = AdapterRegistry::new(); + r.register_adapter(Arc::new(BalancerV2Adapter::default()))?; + r + }; + let mut registration = PoolRegistration::new(PoolKey::BalancerV2(BALANCER_BAL_WETH_POOL_ID)) + .with_state_address(BALANCER_VAULT) + .with_metadata(ProtocolMetadata::BalancerV2(BalancerV2Metadata { + vault: Some(BALANCER_VAULT), + ..Default::default() + })); + registry.cold_start(&mut registration, &mut cache, ColdStartPolicy::Eager)?; + + let config = SimConfig::default(); + let adapter = BalancerV2Adapter::default(); + let sim = adapter + .simulate_swap(®istration, &mut cache, BAL, WETH, amount_in, &config) + .map_err(|e| anyhow!("balancer sim failed: {e}"))?; + + let calldata = Bytes::from( + queryBatchSwapCall { + kind: 0, + swaps: vec![BatchSwapStep { + poolId: BALANCER_BAL_WETH_POOL_ID, + assetInIndex: U256::ZERO, + assetOutIndex: U256::from(1), + amount: amount_in, + userData: Bytes::new(), + }], + assets: vec![BAL, WETH], + funds: FundManagement { + sender: Address::ZERO, + fromInternalBalance: false, + recipient: Address::ZERO, + toInternalBalance: false, + }, + } + .abi_encode(), + ); + let out = eth_call_at(&url, BALANCER_VAULT, calldata, FORK_BLOCK).await?; + let deltas = queryBatchSwapCall::abi_decode_returns_validate(&out)?; + let truth_out = U256::from(deltas[1].unsigned_abs()); + + assert_eq!( + sim.amount_out, truth_out, + "Balancer sim amount_out must match eth_call Vault queryBatchSwap" + ); + Ok(()) +} + +/// Solidly V2 (Aerodrome on Base) parity — forks Base at a pinned block, +/// cold-starts a real Aerodrome WETH/USDC volatile pool, and asserts: +/// 1. cold-start decodes the real `token0`/`token1` from the configured token +/// slots (proves `AERO_TOKEN0_SLOT`/`AERO_TOKEN1_SLOT`), +/// 2. the configured reserve slots hold the pool's authoritative +/// `reserve0()`/`reserve1()` (proves `AERO_RESERVE0_SLOT`/`AERO_RESERVE1_SLOT`), +/// 3. `simulate_swap` (the pool's `getAmountOut`) equals the SAME call via +/// `eth_call` at the fork block (the on-chain ground truth). +/// +/// Together these validate the real `getAmountOut`/`Sync` ABIs *and* the +/// `SolidlyStorageLayout` against a live deployment — the thing the offline +/// `sload(0)` mock cannot exercise. +#[tokio::test(flavor = "multi_thread")] +#[ignore = "requires Base RPC (E2E_BASE_RPC_URL, or E2E_RPC_URL on Alchemy); run with --ignored"] +async fn solidly_simulate_swap_matches_eth_call() -> Result<()> { + let Some(url) = base_rpc_url() else { + eprintln!("no Base RPC (E2E_BASE_RPC_URL / E2E_RPC_URL); skipping"); + return Ok(()); + }; + + // 0.001 WETH in (18 decimals); WETH -> USDC. + let amount_in = U256::from(1_000_000_000_000_000_u64); + + let layout = SolidlyStorageLayout::new( + U256::from(AERO_RESERVE0_SLOT), + U256::from(AERO_RESERVE1_SLOT), + U256::from(AERO_TOKEN0_SLOT), + U256::from(AERO_TOKEN1_SLOT), + ); + + let mut cache = fork_cache(&url, SOLIDLY_FORK_BLOCK).await?; + let registry = { + let mut r = AdapterRegistry::new(); + r.register_adapter(Arc::new(SolidlyV2Adapter::default()))?; + r + }; + let mut registration = PoolRegistration::new(PoolKey::SolidlyV2(AERODROME_WETH_USDC)) + .with_state_address(AERODROME_WETH_USDC) + .with_metadata(ProtocolMetadata::SolidlyV2(SolidlyV2Metadata { + token0: None, + token1: None, + stable: Some(false), + storage_layout: Some(layout), + })); + registry.cold_start(&mut registration, &mut cache, ColdStartPolicy::Eager)?; + + // (1) Cold-start decoded the real tokens from the configured token slots. + let ProtocolMetadata::SolidlyV2(meta) = ®istration.metadata else { + return Err(anyhow!("expected Solidly metadata after cold-start")); + }; + assert_eq!( + meta.token0, + Some(BASE_WETH), + "token0 decoded from slot {AERO_TOKEN0_SLOT} must be WETH" + ); + assert_eq!( + meta.token1, + Some(BASE_USDC), + "token1 decoded from slot {AERO_TOKEN1_SLOT} must be USDC" + ); + + // (2) The configured reserve slots hold the pool's authoritative reserves. + let provider = RootProvider::::connect(&url).await?; + let bid = BlockId::Number(BlockNumberOrTag::Number(SOLIDLY_FORK_BLOCK)); + let slot0 = provider + .get_storage_at(AERODROME_WETH_USDC, U256::from(AERO_RESERVE0_SLOT)) + .block_id(bid) + .await?; + let slot1 = provider + .get_storage_at(AERODROME_WETH_USDC, U256::from(AERO_RESERVE1_SLOT)) + .block_id(bid) + .await?; + let r0 = reserve0Call::abi_decode_returns_validate( + ð_call_at( + &url, + AERODROME_WETH_USDC, + Bytes::from(reserve0Call {}.abi_encode()), + SOLIDLY_FORK_BLOCK, + ) + .await?, + )?; + let r1 = reserve1Call::abi_decode_returns_validate( + ð_call_at( + &url, + AERODROME_WETH_USDC, + Bytes::from(reserve1Call {}.abi_encode()), + SOLIDLY_FORK_BLOCK, + ) + .await?, + )?; + assert_eq!(slot0, r0, "slot {AERO_RESERVE0_SLOT} must hold reserve0()"); + assert_eq!(slot1, r1, "slot {AERO_RESERVE1_SLOT} must hold reserve1()"); + + // (3) simulate_swap == eth_call getAmountOut ground truth. + let config = SimConfig::default(); + let adapter = SolidlyV2Adapter::default(); + let sim = adapter + .simulate_swap( + ®istration, + &mut cache, + BASE_WETH, + BASE_USDC, + amount_in, + &config, + ) + .map_err(|e| anyhow!("solidly sim failed: {e}"))?; + + let out = eth_call_at( + &url, + AERODROME_WETH_USDC, + Bytes::from( + getAmountOutCall { + amountIn: amount_in, + tokenIn: BASE_WETH, + } + .abi_encode(), + ), + SOLIDLY_FORK_BLOCK, + ) + .await?; + let truth = getAmountOutCall::abi_decode_returns_validate(&out)?; + + assert!(truth > U256::ZERO, "ground-truth quote should be non-zero"); + assert_eq!( + sim.amount_out, truth, + "Solidly sim amount_out must match eth_call getAmountOut" + ); + Ok(()) +} diff --git a/tests/cold_start_adoption.rs b/tests/cold_start_adoption.rs index 3671a3b..14feca0 100644 --- a/tests/cold_start_adoption.rs +++ b/tests/cold_start_adoption.rs @@ -10,21 +10,25 @@ use std::collections::{HashMap, HashSet}; use std::sync::Arc; use alloy_eips::BlockId; -use alloy_primitives::{Address, U256}; +use alloy_primitives::{Address, B256, Bytes, U256, hex}; use alloy_provider::{RootProvider, network::AnyNetwork}; use alloy_rpc_client::RpcClient; use alloy_transport::mock::Asserter; use anyhow::{Result, anyhow}; +use evm_amm_state::adapters::storage::SolidlyStorageLayout; use evm_amm_state::adapters::storage::{ V2_RESERVES_SLOT, V2_TOKEN0_SLOT, V2_TOKEN1_SLOT, V3StorageLayout, + v3_tick_bitmap_storage_key_with_base, v3_tick_info_storage_keys_with_base, v3_word_position, }; use evm_amm_state::adapters::{ - AdapterRegistry, ColdStartOutcome, ColdStartPolicy, DeferredWork, PoolKey, PoolRegistration, - PoolStatus, ProtocolMetadata, UniswapV2Adapter, UniswapV2Metadata, UniswapV3Adapter, - V3Metadata, + AdapterRegistry, BalancerV2Adapter, BalancerV2Metadata, ColdStartOutcome, ColdStartPolicy, + DeferredWork, PoolKey, PoolRegistration, PoolStatus, ProtocolMetadata, RepairAction, + SolidlyV2Adapter, SolidlyV2Metadata, UniswapV2Adapter, UniswapV2Metadata, UniswapV3Adapter, + UnsupportedReason, V3Metadata, }; use evm_fork_cache::cache::{EvmCache, StorageBatchFetchFn}; +use revm::state::{AccountInfo, Bytecode}; // --- helpers (kept local so this manager file owns its fixtures) --- @@ -251,6 +255,54 @@ async fn v2_cold_start_lazy_defers_exactly_what_eager_warms() -> Result<()> { Ok(()) } +// A Lazy cold-start records its token slots as deferred work but does not warm +// them; `run_deferred` must execute that deferred work and warm them. +#[tokio::test] +async fn v2_run_deferred_warms_lazy_deferred_slots() -> Result<()> { + let pool = Address::repeat_byte(0x14); + let token0 = Address::repeat_byte(0xb0); + let token1 = Address::repeat_byte(0xb1); + let mut cache = setup_cache().await?; + cache.set_storage_batch_fetcher(fetcher_with_failures( + HashMap::from([ + ((pool, V2_TOKEN0_SLOT), token_slot_word(token0)), + ((pool, V2_TOKEN1_SLOT), token_slot_word(token1)), + ( + (pool, V2_RESERVES_SLOT), + reserves_slot(U256::from(1_u64), U256::from(2_u64), U256::ZERO), + ), + ]), + Vec::new(), + )); + + let registry = v2_registry(); + let mut registration = PoolRegistration::new(PoolKey::UniswapV2(pool)).with_state_address(pool); + let outcome = registry.cold_start(&mut registration, &mut cache, ColdStartPolicy::Lazy)?; + let deferred = match outcome { + ColdStartOutcome::ReadyWithDeferred(_, d) => d, + other => panic!("Lazy should be ReadyWithDeferred, got {other:?}"), + }; + // Lazy did not warm the token slots up-front. + assert_eq!(cache.cached_storage_value(pool, V2_TOKEN0_SLOT), None); + + // Drive the deferred work; the deferred token slots are now warmed. + registry.run_deferred(&deferred, &mut cache)?; + + assert!( + cache.cached_storage_value(pool, V2_TOKEN0_SLOT).is_some(), + "run_deferred must warm deferred token0" + ); + assert!( + cache.cached_storage_value(pool, V2_TOKEN1_SLOT).is_some(), + "run_deferred must warm deferred token1" + ); + assert!( + cache.cached_storage_value(pool, V2_RESERVES_SLOT).is_some(), + "reserves stay warm" + ); + Ok(()) +} + // --- Uniswap V3 --- #[tokio::test] @@ -298,6 +350,154 @@ async fn v3_cold_start_ready_warms_slot0_and_liquidity() -> Result<()> { Ok(()) } +/// The bitmap bit for `tick` within its word: `floor(tick/spacing) mod 256`. +fn v3_bit(tick: i32, spacing: i32) -> U256 { + let bit = tick.div_euclid(spacing).rem_euclid(256) as u32; + U256::from(1) << bit +} + +// Eager cold-start must warm a bounded WINDOW of neighbouring tick-bitmap words +// (and their initialized ticks), not just the current word — so a moderate +// tick-crossing swap is offline-pre-warmed. Currently only the current word is +// warmed, so the W0±1 bitmap + their tick-info slots are unfetched (None) -> red. +#[tokio::test] +async fn v3_cold_start_warms_neighbouring_tick_words() -> Result<()> { + let pool = Address::repeat_byte(0x24); + let layout = V3StorageLayout::uniswap(60); + let spacing = 60i32; + + // Current tick 0 -> word 0; neighbours -1 and +1. + let w0 = v3_word_position(0, spacing); + let key_w0 = v3_tick_bitmap_storage_key_with_base(w0, layout.tick_bitmap_base_slot); + let key_wp1 = v3_tick_bitmap_storage_key_with_base(w0 + 1, layout.tick_bitmap_base_slot); + let key_wm1 = v3_tick_bitmap_storage_key_with_base(w0 - 1, layout.tick_bitmap_base_slot); + + // One initialized tick per word (self-checked placement). + let tick_w0 = 60; // word 0, bit 1 + let tick_wp1 = 256 * 60; // word +1, bit 0 + let tick_wm1 = -60; // word -1, bit 255 + assert_eq!(v3_word_position(tick_w0, spacing), w0); + assert_eq!(v3_word_position(tick_wp1, spacing), w0 + 1); + assert_eq!(v3_word_position(tick_wm1, spacing), w0 - 1); + + let info_w0 = v3_tick_info_storage_keys_with_base(tick_w0, layout.ticks_base_slot); + let info_wp1 = v3_tick_info_storage_keys_with_base(tick_wp1, layout.ticks_base_slot); + let info_wm1 = v3_tick_info_storage_keys_with_base(tick_wm1, layout.ticks_base_slot); + + let mut cache = setup_cache().await?; + cache.set_storage_batch_fetcher(fetcher_with_failures( + HashMap::from([ + ( + (pool, layout.slot0_slot), + v3_slot0_word(U256::from(99_u64), 0, U256::ZERO), + ), + ((pool, layout.liquidity_slot), U256::from(5_u64)), + ((pool, key_w0), v3_bit(tick_w0, spacing)), + ((pool, key_wp1), v3_bit(tick_wp1, spacing)), + ((pool, key_wm1), v3_bit(tick_wm1, spacing)), + ((pool, info_w0[0]), U256::from(1_u64)), + ((pool, info_w0[3]), U256::from(1_u64)), + ((pool, info_wp1[0]), U256::from(1_u64)), + ((pool, info_wp1[3]), U256::from(1_u64)), + ((pool, info_wm1[0]), U256::from(1_u64)), + ((pool, info_wm1[3]), U256::from(1_u64)), + ]), + Vec::new(), + )); + + let registry = v3_registry(); + let mut registration = PoolRegistration::new(PoolKey::UniswapV3(pool)) + .with_state_address(pool) + .with_metadata(ProtocolMetadata::UniswapV3(V3Metadata { + storage_layout: Some(layout), + tick_spacing: Some(60), + ..Default::default() + })); + + let outcome = registry.cold_start(&mut registration, &mut cache, ColdStartPolicy::Eager)?; + assert!( + matches!(outcome, ColdStartOutcome::Ready(_)), + "got {outcome:?}" + ); + + // Current word still warmed (regression). + assert!(cache.cached_storage_value(pool, key_w0).is_some()); + assert!(cache.cached_storage_value(pool, info_w0[0]).is_some()); + // Neighbouring words + their initialized ticks warmed (the new behaviour). + assert!( + cache.cached_storage_value(pool, key_wp1).is_some(), + "word +1 bitmap must be warmed" + ); + assert!( + cache.cached_storage_value(pool, key_wm1).is_some(), + "word -1 bitmap must be warmed" + ); + assert!( + cache.cached_storage_value(pool, info_wp1[0]).is_some(), + "word +1 tick info must be warmed" + ); + assert!( + cache.cached_storage_value(pool, info_wm1[0]).is_some(), + "word -1 tick info must be warmed" + ); + Ok(()) +} + +// Policy boundary: HotSlotsOnly warms only slot0 + liquidity — NO tick bitmap +// words (current or neighbouring). Guards the multi-word scan from leaking into +// the hot-only policy. +#[tokio::test] +async fn v3_cold_start_hot_slots_only_skips_tick_words() -> Result<()> { + let pool = Address::repeat_byte(0x25); + let layout = V3StorageLayout::uniswap(60); + let key_w0 = + v3_tick_bitmap_storage_key_with_base(v3_word_position(0, 60), layout.tick_bitmap_base_slot); + + let mut cache = setup_cache().await?; + cache.set_storage_batch_fetcher(fetcher_with_failures( + HashMap::from([ + ( + (pool, layout.slot0_slot), + v3_slot0_word(U256::from(99_u64), 0, U256::ZERO), + ), + ((pool, layout.liquidity_slot), U256::from(5_u64)), + ((pool, key_w0), v3_bit(60, 60)), + ]), + Vec::new(), + )); + + let registry = v3_registry(); + let mut registration = PoolRegistration::new(PoolKey::UniswapV3(pool)) + .with_state_address(pool) + .with_metadata(ProtocolMetadata::UniswapV3(V3Metadata { + storage_layout: Some(layout), + tick_spacing: Some(60), + ..Default::default() + })); + + let outcome = + registry.cold_start(&mut registration, &mut cache, ColdStartPolicy::HotSlotsOnly)?; + assert!( + matches!(outcome, ColdStartOutcome::Ready(_)), + "got {outcome:?}" + ); + assert!( + cache + .cached_storage_value(pool, layout.slot0_slot) + .is_some() + ); + assert!( + cache + .cached_storage_value(pool, layout.liquidity_slot) + .is_some() + ); + assert!( + cache.cached_storage_value(pool, key_w0).is_none(), + "HotSlotsOnly must not warm any tick bitmap word" + ); + Ok(()) +} + #[tokio::test] async fn v3_cold_start_missing_layout_is_unsupported() -> Result<()> { let pool = Address::repeat_byte(0x22); @@ -345,3 +545,616 @@ async fn v3_cold_start_failed_slot0_needs_repair() -> Result<()> { ); Ok(()) } + +// --- Balancer V2 (slice 2: discover -> verify access-list cold start) --- + +/// A mocked-provider cache plus the asserter, so a test can prove no RPC was +/// issued (`asserter.read_q().is_empty()`). +async fn setup_cache_with_asserter() -> Result<(EvmCache, Asserter)> { + let asserter = Asserter::new(); + let client = RpcClient::mocked(asserter.clone()); + let provider = RootProvider::::new(client); + Ok((EvmCache::new(Arc::new(provider)).await, asserter)) +} + +fn install_default_account(cache: &mut EvmCache, addr: Address) { + cache + .db_mut() + .insert_account_info(addr, AccountInfo::default()); +} + +/// Install raw runtime bytecode (a compiled mock-vault fixture) at `vault`. +fn install_vault_runtime(cache: &mut EvmCache, vault: Address, runtime: &str) { + let code = Bytecode::new_raw(Bytes::from( + hex::decode(runtime.trim()).expect("valid mock-vault runtime hex"), + )); + let code_hash = code.hash_slow(); + cache.db_mut().insert_account_info( + vault, + AccountInfo { + balance: U256::ZERO, + nonce: 1, + code: Some(code), + code_hash, + account_id: None, + }, + ); +} + +/// Install the compiled `MockBalancerVault` stub at `vault`. Its +/// `getPoolTokens(bytes32)` SLOADs fixed slots 0..=4 and returns the dynamic +/// `(address[] tokens, uint256[] balances, uint256 lastChangeBlock)` tuple built +/// from them (length 2). +fn install_mock_vault(cache: &mut EvmCache, vault: Address) { + install_vault_runtime( + cache, + vault, + include_str!("fixtures/mock_balancer_vault_runtime.hex"), + ); +} + +fn balancer_registry() -> AdapterRegistry { + let mut registry = AdapterRegistry::new(); + registry + .register_adapter(Arc::new(BalancerV2Adapter::default())) + .unwrap(); + registry +} + +#[tokio::test(flavor = "multi_thread")] +async fn balancer_cold_start_discover_verify_ready() -> Result<()> { + let vault = Address::repeat_byte(0x31); + // Distinct leading-20 / trailing-12 so the pool_address derivation + // (leading 20 bytes of the poolId) can't accidentally pass via a wrong + // slice range or byte order. + let mut pid = [0u8; 32]; + pid[..20].fill(0x11); + pid[20..].fill(0x22); + let pool_id = B256::from(pid); + let token0 = Address::repeat_byte(0xc0); + let token1 = Address::repeat_byte(0xc1); + + let (mut cache, asserter) = setup_cache_with_asserter().await?; + // The block beneficiary (default Address::ZERO) is credited gas during the + // discover call's transact; install it so the offline run does not fetch it. + install_default_account(&mut cache, Address::ZERO); + install_mock_vault(&mut cache, vault); + // Seed the vault's fixed slots 0..=4 that getPoolTokens SLOADs. Token slots + // (0,1) hold the immutable token addresses; balance slots (2,3) hold STALE + // values, so the verify round must refresh them to the fetcher's fresh ones. + cache + .db_mut() + .insert_account_storage(vault, U256::from(0), token_slot_word(token0))?; + cache + .db_mut() + .insert_account_storage(vault, U256::from(1), token_slot_word(token1))?; + cache + .db_mut() + .insert_account_storage(vault, U256::from(2), U256::from(1_u64))?; + cache + .db_mut() + .insert_account_storage(vault, U256::from(3), U256::from(2_u64))?; + cache + .db_mut() + .insert_account_storage(vault, U256::from(4), U256::from(7_u64))?; + // Round 2's verify fetcher returns FRESH balances for the discovered slots. + cache.set_storage_batch_fetcher(fetcher_with_failures( + HashMap::from([ + ((vault, U256::from(0)), token_slot_word(token0)), + ((vault, U256::from(1)), token_slot_word(token1)), + ((vault, U256::from(2)), U256::from(1000_u64)), + ((vault, U256::from(3)), U256::from(2000_u64)), + ((vault, U256::from(4)), U256::from(7_u64)), + ]), + Vec::new(), + )); + + let registry = balancer_registry(); + let mut registration = PoolRegistration::new(PoolKey::BalancerV2(pool_id)) + .with_state_address(vault) + .with_metadata(ProtocolMetadata::BalancerV2(BalancerV2Metadata { + vault: Some(vault), + ..Default::default() + })); + + let outcome = registry.cold_start(&mut registration, &mut cache, ColdStartPolicy::Eager)?; + + assert!( + matches!(outcome, ColdStartOutcome::Ready(_)), + "discover->verify should reach Ready, got {outcome:?}" + ); + assert_eq!(registration.status, PoolStatus::Ready); + match registration.metadata { + ProtocolMetadata::BalancerV2(ref m) => { + assert_eq!( + m.tokens, + vec![token0, token1], + "tokens decoded from the getPoolTokens return data" + ); + assert_eq!(m.vault, Some(vault)); + // pool_address is the leading 20 bytes of the poolId (Balancer + // poolId = address(20) | specialization | nonce). + assert_eq!( + m.pool_address, + Some(Address::repeat_byte(0x11)), + "pool_address must be the leading 20 bytes of the poolId" + ); + } + ref other => panic!("expected BalancerV2 metadata, got {other:?}"), + } + // The verify round refreshed the discovered balance slots to the fetcher's + // fresh values (proving discover -> verify warmed them, not the stale seed). + assert_eq!( + cache.cached_storage_value(vault, U256::from(2)), + Some(U256::from(1000_u64)) + ); + assert_eq!( + cache.cached_storage_value(vault, U256::from(3)), + Some(U256::from(2000_u64)) + ); + assert!( + asserter.read_q().is_empty(), + "the cold start must be fully offline (no RPC)" + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn balancer_cold_start_missing_vault_is_unsupported() -> Result<()> { + let pool_id = B256::repeat_byte(0x33); + let mut cache = setup_cache().await?; + cache.set_storage_batch_fetcher(fetcher_with_failures(HashMap::new(), Vec::new())); + + let registry = balancer_registry(); + // No vault metadata and no state_addresses -> the vault is unresolvable. + let mut registration = PoolRegistration::new(PoolKey::BalancerV2(pool_id)) + .with_metadata(ProtocolMetadata::BalancerV2(BalancerV2Metadata::default())); + + let outcome = registry.cold_start(&mut registration, &mut cache, ColdStartPolicy::Eager)?; + assert!( + matches!( + outcome, + ColdStartOutcome::Unsupported(UnsupportedReason::MissingMetadata("Balancer vault")) + ), + "a vault-less Balancer pool must be Unsupported for the vault reason, got {outcome:?}" + ); + Ok(()) +} + +// Drives the empty-capture branch: getPoolTokens decodes fine but touches no +// vault storage, so the discovery yields an empty `(vault, slot)` set. The +// repair must re-discover (`ColdStart`), NOT purge the shared singleton vault's +// storage (which would wipe every co-tenant Balancer pool). +#[tokio::test(flavor = "multi_thread")] +async fn balancer_cold_start_empty_capture_repairs_via_coldstart() -> Result<()> { + let vault = Address::repeat_byte(0x41); + let pool_id = B256::repeat_byte(0x42); + + let (mut cache, asserter) = setup_cache_with_asserter().await?; + install_default_account(&mut cache, Address::ZERO); + install_vault_runtime( + &mut cache, + vault, + include_str!("fixtures/mock_balancer_vault_noslot_runtime.hex"), + ); + + let registry = balancer_registry(); + let mut registration = PoolRegistration::new(PoolKey::BalancerV2(pool_id)) + .with_state_address(vault) + .with_metadata(ProtocolMetadata::BalancerV2(BalancerV2Metadata { + vault: Some(vault), + ..Default::default() + })); + + let outcome = registry.cold_start(&mut registration, &mut cache, ColdStartPolicy::Eager)?; + assert!( + matches!( + outcome, + ColdStartOutcome::NeedsRepair(_, RepairAction::ColdStart { .. }) + ), + "empty capture must re-discover, not purge the shared vault, got {outcome:?}" + ); + assert_ne!(registration.status, PoolStatus::Ready); + assert!(asserter.read_q().is_empty(), "must be fully offline"); + Ok(()) +} + +// A getPoolTokens that reverts must be classified as a failed discovery +// (NeedsRepair via re-discovery), never silently driven to Ready. +#[tokio::test(flavor = "multi_thread")] +async fn balancer_cold_start_reverting_call_needs_repair() -> Result<()> { + let vault = Address::repeat_byte(0x51); + let pool_id = B256::repeat_byte(0x52); + + let (mut cache, asserter) = setup_cache_with_asserter().await?; + install_default_account(&mut cache, Address::ZERO); + install_vault_runtime( + &mut cache, + vault, + include_str!("fixtures/mock_balancer_vault_revert_runtime.hex"), + ); + + let registry = balancer_registry(); + let mut registration = PoolRegistration::new(PoolKey::BalancerV2(pool_id)) + .with_state_address(vault) + .with_metadata(ProtocolMetadata::BalancerV2(BalancerV2Metadata { + vault: Some(vault), + ..Default::default() + })); + + let outcome = registry.cold_start(&mut registration, &mut cache, ColdStartPolicy::Eager)?; + assert!( + matches!( + outcome, + ColdStartOutcome::NeedsRepair(_, RepairAction::ColdStart { .. }) + ), + "a reverting getPoolTokens must need repair, not reach Ready, got {outcome:?}" + ); + assert_ne!(registration.status, PoolStatus::Ready); + assert!(asserter.read_q().is_empty(), "must be fully offline"); + Ok(()) +} + +// The verify round warms the discovered balance slots. If one is unfetchable (an +// archive miss), the pool must NOT be marked Ready with unwarmed balances — it +// must need repair, mirroring the V2/V3 mandatory-slot behavior. +#[tokio::test(flavor = "multi_thread")] +async fn balancer_cold_start_failed_balance_slot_needs_repair() -> Result<()> { + let vault = Address::repeat_byte(0x61); + let pool_id = B256::repeat_byte(0x62); + let token0 = Address::repeat_byte(0xc0); + let token1 = Address::repeat_byte(0xc1); + + let (mut cache, _asserter) = setup_cache_with_asserter().await?; + install_default_account(&mut cache, Address::ZERO); + install_mock_vault(&mut cache, vault); + cache + .db_mut() + .insert_account_storage(vault, U256::from(0), token_slot_word(token0))?; + cache + .db_mut() + .insert_account_storage(vault, U256::from(1), token_slot_word(token1))?; + cache + .db_mut() + .insert_account_storage(vault, U256::from(2), U256::from(1_u64))?; + cache + .db_mut() + .insert_account_storage(vault, U256::from(3), U256::from(2_u64))?; + cache + .db_mut() + .insert_account_storage(vault, U256::from(4), U256::from(7_u64))?; + // Verify round: slot 2 (a discovered balance slot) fails to fetch. + cache.set_storage_batch_fetcher(fetcher_with_failures( + HashMap::from([ + ((vault, U256::from(0)), token_slot_word(token0)), + ((vault, U256::from(1)), token_slot_word(token1)), + ((vault, U256::from(3)), U256::from(2000_u64)), + ((vault, U256::from(4)), U256::from(7_u64)), + ]), + vec![(vault, U256::from(2))], + )); + + let registry = balancer_registry(); + let mut registration = PoolRegistration::new(PoolKey::BalancerV2(pool_id)) + .with_state_address(vault) + .with_metadata(ProtocolMetadata::BalancerV2(BalancerV2Metadata { + vault: Some(vault), + ..Default::default() + })); + + let outcome = registry.cold_start(&mut registration, &mut cache, ColdStartPolicy::Eager)?; + assert!( + matches!(outcome, ColdStartOutcome::NeedsRepair(_, _)), + "an unfetchable discovered balance slot must need repair, got {outcome:?}" + ); + assert_ne!(registration.status, PoolStatus::Ready); + Ok(()) +} + +// The decode + slot capture must generalize beyond two tokens (real weighted / +// stable pools hold 3..8). +#[tokio::test(flavor = "multi_thread")] +async fn balancer_cold_start_three_tokens_ready() -> Result<()> { + let vault = Address::repeat_byte(0x71); + let pool_id = B256::repeat_byte(0x72); + let token0 = Address::repeat_byte(0xc0); + let token1 = Address::repeat_byte(0xc1); + let token2 = Address::repeat_byte(0xc2); + + let (mut cache, asserter) = setup_cache_with_asserter().await?; + install_default_account(&mut cache, Address::ZERO); + install_vault_runtime( + &mut cache, + vault, + include_str!("fixtures/mock_balancer_vault_3_runtime.hex"), + ); + // Slots 0..=6: token0,token1,token2, balance0,balance1,balance2, lastChangeBlock. + cache + .db_mut() + .insert_account_storage(vault, U256::from(0), token_slot_word(token0))?; + cache + .db_mut() + .insert_account_storage(vault, U256::from(1), token_slot_word(token1))?; + cache + .db_mut() + .insert_account_storage(vault, U256::from(2), token_slot_word(token2))?; + cache + .db_mut() + .insert_account_storage(vault, U256::from(3), U256::from(1_u64))?; + cache + .db_mut() + .insert_account_storage(vault, U256::from(4), U256::from(2_u64))?; + cache + .db_mut() + .insert_account_storage(vault, U256::from(5), U256::from(3_u64))?; + cache + .db_mut() + .insert_account_storage(vault, U256::from(6), U256::from(9_u64))?; + cache.set_storage_batch_fetcher(fetcher_with_failures( + HashMap::from([ + ((vault, U256::from(0)), token_slot_word(token0)), + ((vault, U256::from(1)), token_slot_word(token1)), + ((vault, U256::from(2)), token_slot_word(token2)), + ((vault, U256::from(3)), U256::from(1000_u64)), + ((vault, U256::from(4)), U256::from(2000_u64)), + ((vault, U256::from(5)), U256::from(3000_u64)), + ((vault, U256::from(6)), U256::from(9_u64)), + ]), + Vec::new(), + )); + + let registry = balancer_registry(); + let mut registration = PoolRegistration::new(PoolKey::BalancerV2(pool_id)) + .with_state_address(vault) + .with_metadata(ProtocolMetadata::BalancerV2(BalancerV2Metadata { + vault: Some(vault), + ..Default::default() + })); + + let outcome = registry.cold_start(&mut registration, &mut cache, ColdStartPolicy::Eager)?; + assert!( + matches!(outcome, ColdStartOutcome::Ready(_)), + "got {outcome:?}" + ); + match registration.metadata { + ProtocolMetadata::BalancerV2(ref m) => { + assert_eq!(m.tokens, vec![token0, token1, token2], "3-token decode"); + } + ref other => panic!("expected BalancerV2 metadata, got {other:?}"), + } + // All three discovered balance slots were refreshed by the verify round. + assert_eq!( + cache.cached_storage_value(vault, U256::from(3)), + Some(U256::from(1000_u64)) + ); + assert_eq!( + cache.cached_storage_value(vault, U256::from(5)), + Some(U256::from(3000_u64)) + ); + assert!(asserter.read_q().is_empty(), "must be fully offline"); + Ok(()) +} + +// --- Solidly V2 --- + +fn solidly_registry() -> AdapterRegistry { + let mut registry = AdapterRegistry::new(); + registry + .register_adapter(Arc::new(SolidlyV2Adapter::default())) + .unwrap(); + registry +} + +// Eager cold-start warms both reserve slots (two separate uint256 slots, unlike +// V2's packed slot) plus the token slots, and reaches Ready. +#[tokio::test] +async fn solidly_cold_start_ready_warms_reserves_and_tokens() -> Result<()> { + let pool = Address::repeat_byte(0x51); + let token0 = Address::repeat_byte(0xc0); + let token1 = Address::repeat_byte(0xc1); + // Arbitrary test layout — the adapter verifies whatever the layout names. + let (r0_slot, r1_slot, t0_slot, t1_slot) = ( + U256::from(10_u64), + U256::from(11_u64), + U256::from(12_u64), + U256::from(13_u64), + ); + let layout = SolidlyStorageLayout::new(r0_slot, r1_slot, t0_slot, t1_slot); + + let mut cache = setup_cache().await?; + cache.set_storage_batch_fetcher(fetcher_with_failures( + HashMap::from([ + ((pool, r0_slot), U256::from(1_000_u64)), + ((pool, r1_slot), U256::from(2_000_u64)), + ((pool, t0_slot), token_slot_word(token0)), + ((pool, t1_slot), token_slot_word(token1)), + ]), + Vec::new(), + )); + + let registry = solidly_registry(); + let mut registration = PoolRegistration::new(PoolKey::SolidlyV2(pool)) + .with_state_address(pool) + .with_metadata(ProtocolMetadata::SolidlyV2(SolidlyV2Metadata { + stable: Some(false), + storage_layout: Some(layout), + ..Default::default() + })); + + let outcome = registry.cold_start(&mut registration, &mut cache, ColdStartPolicy::Eager)?; + assert!( + matches!(outcome, ColdStartOutcome::Ready(_)), + "got {outcome:?}" + ); + assert_eq!(registration.status, PoolStatus::Ready); + assert!(cache.cached_storage_value(pool, r0_slot).is_some()); + assert!(cache.cached_storage_value(pool, r1_slot).is_some()); + assert!(cache.cached_storage_value(pool, t0_slot).is_some()); + assert!(cache.cached_storage_value(pool, t1_slot).is_some()); + Ok(()) +} + +// Reserves are mandatory: a genuine on-chain zero (degenerate pool) and an +// archive miss must produce DISTINCT repairs (the per-slot SlotFetch point), +// mirroring the V2 adapter. +#[tokio::test] +async fn solidly_cold_start_zero_vs_failed_reserves_are_distinct_repairs() -> Result<()> { + let pool = Address::repeat_byte(0x52); + let layout = SolidlyStorageLayout::new( + U256::from(10_u64), + U256::from(11_u64), + U256::from(12_u64), + U256::from(13_u64), + ); + let metadata = || { + ProtocolMetadata::SolidlyV2(SolidlyV2Metadata { + stable: Some(false), + storage_layout: Some(layout), + ..Default::default() + }) + }; + + // Case A: reserves read a genuine on-chain ZERO (degenerate pool). + let mut cache_zero = setup_cache().await?; + cache_zero.set_storage_batch_fetcher(fetcher_with_failures(HashMap::new(), Vec::new())); + let mut reg_zero = PoolRegistration::new(PoolKey::SolidlyV2(pool)) + .with_state_address(pool) + .with_metadata(metadata()); + let zero = + solidly_registry().cold_start(&mut reg_zero, &mut cache_zero, ColdStartPolicy::Eager)?; + + // Case B: reserve0 FAILS to fetch (archive / historical miss). + let mut cache_fail = setup_cache().await?; + cache_fail.set_storage_batch_fetcher(fetcher_with_failures( + HashMap::new(), + vec![(pool, layout.reserve0_slot)], + )); + let mut reg_fail = PoolRegistration::new(PoolKey::SolidlyV2(pool)) + .with_state_address(pool) + .with_metadata(metadata()); + let fail = + solidly_registry().cold_start(&mut reg_fail, &mut cache_fail, ColdStartPolicy::Eager)?; + + assert!( + matches!(zero, ColdStartOutcome::NeedsRepair(_, _)), + "genuine-zero reserves should need repair, got {zero:?}" + ); + assert!( + matches!(fail, ColdStartOutcome::NeedsRepair(_, _)), + "archive-miss reserves should need repair, got {fail:?}" + ); + assert_ne!( + repair_of(&zero), + repair_of(&fail), + "a genuine zero and an archive miss must produce different repairs" + ); + Ok(()) +} + +// A layout whose slots collide (here reserve0 == token0) is rejected at the +// planner boundary rather than silently corrupting the verdict / token decode. +#[tokio::test] +async fn solidly_cold_start_colliding_layout_is_unsupported() -> Result<()> { + let pool = Address::repeat_byte(0x53); + let mut cache = setup_cache().await?; + cache.set_storage_batch_fetcher(fetcher_with_failures(HashMap::new(), Vec::new())); + let registry = solidly_registry(); + // reserve0_slot == token0_slot (both 10) -> colliding. + let mut registration = PoolRegistration::new(PoolKey::SolidlyV2(pool)) + .with_state_address(pool) + .with_metadata(ProtocolMetadata::SolidlyV2(SolidlyV2Metadata { + stable: Some(false), + storage_layout: Some(SolidlyStorageLayout::new( + U256::from(10_u64), + U256::from(11_u64), + U256::from(10_u64), + U256::from(13_u64), + )), + ..Default::default() + })); + let outcome = registry.cold_start(&mut registration, &mut cache, ColdStartPolicy::Eager)?; + assert!( + matches!(outcome, ColdStartOutcome::Unsupported(_)), + "a colliding layout must be Unsupported, got {outcome:?}" + ); + Ok(()) +} + +// Lazy warms the reserves now and defers exactly the token slots; HotSlotsOnly +// warms reserves only and does NOT defer. +#[tokio::test] +async fn solidly_cold_start_lazy_defers_token_slots() -> Result<()> { + let pool = Address::repeat_byte(0x54); + let token0 = Address::repeat_byte(0xc0); + let token1 = Address::repeat_byte(0xc1); + let (r0, r1, t0, t1) = ( + U256::from(10_u64), + U256::from(11_u64), + U256::from(12_u64), + U256::from(13_u64), + ); + let layout = SolidlyStorageLayout::new(r0, r1, t0, t1); + let seed = || { + HashMap::from([ + ((pool, r0), U256::from(1_000_u64)), + ((pool, r1), U256::from(2_000_u64)), + ((pool, t0), token_slot_word(token0)), + ((pool, t1), token_slot_word(token1)), + ]) + }; + + // Lazy: ReadyWithDeferred, reserves warm, tokens deferred (not warm). + let mut cache = setup_cache().await?; + cache.set_storage_batch_fetcher(fetcher_with_failures(seed(), Vec::new())); + let registry = solidly_registry(); + let mut reg = PoolRegistration::new(PoolKey::SolidlyV2(pool)) + .with_state_address(pool) + .with_metadata(ProtocolMetadata::SolidlyV2(SolidlyV2Metadata { + stable: Some(false), + storage_layout: Some(layout), + ..Default::default() + })); + let outcome = registry.cold_start(&mut reg, &mut cache, ColdStartPolicy::Lazy)?; + let deferred = match outcome { + ColdStartOutcome::ReadyWithDeferred(_, d) => d, + other => panic!("Lazy should be ReadyWithDeferred, got {other:?}"), + }; + let deferred_slots: HashSet<(Address, U256)> = deferred + .iter() + .flat_map(|w| match w { + DeferredWork::VerifySlots(slots) => slots.clone(), + _ => Vec::new(), + }) + .collect(); + assert!(deferred_slots.contains(&(pool, t0)) && deferred_slots.contains(&(pool, t1))); + assert!(cache.cached_storage_value(pool, r0).is_some()); + assert_eq!( + cache.cached_storage_value(pool, t0), + None, + "tokens deferred, not warm" + ); + + // run_deferred warms the deferred token slots. + registry.run_deferred(&deferred, &mut cache)?; + assert!(cache.cached_storage_value(pool, t0).is_some()); + assert!(cache.cached_storage_value(pool, t1).is_some()); + + // HotSlotsOnly: plain Ready (no defer), reserves warm, tokens not warm. + let mut cache_h = setup_cache().await?; + cache_h.set_storage_batch_fetcher(fetcher_with_failures(seed(), Vec::new())); + let mut reg_h = PoolRegistration::new(PoolKey::SolidlyV2(pool)) + .with_state_address(pool) + .with_metadata(ProtocolMetadata::SolidlyV2(SolidlyV2Metadata { + stable: Some(false), + storage_layout: Some(layout), + ..Default::default() + })); + let outcome_h = registry.cold_start(&mut reg_h, &mut cache_h, ColdStartPolicy::HotSlotsOnly)?; + assert!( + matches!(outcome_h, ColdStartOutcome::Ready(_)), + "HotSlotsOnly should be plain Ready (no defer), got {outcome_h:?}" + ); + assert!(cache_h.cached_storage_value(pool, r0).is_some()); + assert_eq!(cache_h.cached_storage_value(pool, t0), None); + Ok(()) +} diff --git a/tests/fixtures/MockBalancerVault.sol b/tests/fixtures/MockBalancerVault.sol new file mode 100644 index 0000000..5e39f5a --- /dev/null +++ b/tests/fixtures/MockBalancerVault.sol @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +/// Minimal Balancer-vault stub for offline cold-start discover tests. +/// +/// `getPoolTokens` SLOADs five FIXED slots (0..=4) — token0, token1, balance0, +/// balance1, lastChangeBlock — so a `call_raw_with_access_list` captures a +/// deterministic `(vault, slot)` set, and returns the decoded +/// `(address[] tokens, uint256[] balances, uint256 lastChangeBlock)` tuple built +/// from them. The arrays are DYNAMIC, matching the real Balancer V2 Vault ABI +/// (`getPoolTokens(bytes32) returns (IERC20[], uint256[], uint256)`); do not +/// "correct" them to fixed-size `address[2]`/`uint256[2]` (incompatible wire +/// encoding). The poolId argument is ignored (the planner is +/// storage-layout-agnostic: it verifies whatever slots the call touches). +contract MockBalancerVault { + function getPoolTokens(bytes32) + external + view + returns (address[] memory tokens, uint256[] memory balances, uint256 lastChangeBlock) + { + uint256 t0; + uint256 t1; + uint256 b0; + uint256 b1; + uint256 lcb; + assembly { + t0 := sload(0) + t1 := sload(1) + b0 := sload(2) + b1 := sload(3) + lcb := sload(4) + } + tokens = new address[](2); + tokens[0] = address(uint160(t0)); + tokens[1] = address(uint160(t1)); + balances = new uint256[](2); + balances[0] = b0; + balances[1] = b1; + lastChangeBlock = lcb; + } +} diff --git a/tests/fixtures/MockBalancerVault3.sol b/tests/fixtures/MockBalancerVault3.sol new file mode 100644 index 0000000..45fa4fd --- /dev/null +++ b/tests/fixtures/MockBalancerVault3.sol @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +/// Three-token Balancer-vault stub, to exercise dynamic-array decode for N != 2. +/// +/// Like `MockBalancerVault` but SLOADs seven FIXED slots (0..=6) — token0..2, +/// balance0..2, lastChangeBlock — and returns dynamic `address[3]`/`uint256[3]` +/// arrays (real weighted/stable pools hold 3..8 tokens). Proves the planner's +/// `getPoolTokens` decode and the warmed `(vault, slot)` capture generalise +/// beyond the 2-token happy path. +contract MockBalancerVault3 { + function getPoolTokens(bytes32) + external + view + returns (address[] memory tokens, uint256[] memory balances, uint256 lastChangeBlock) + { + uint256 t0; + uint256 t1; + uint256 t2; + uint256 b0; + uint256 b1; + uint256 b2; + uint256 lcb; + assembly { + t0 := sload(0) + t1 := sload(1) + t2 := sload(2) + b0 := sload(3) + b1 := sload(4) + b2 := sload(5) + lcb := sload(6) + } + tokens = new address[](3); + tokens[0] = address(uint160(t0)); + tokens[1] = address(uint160(t1)); + tokens[2] = address(uint160(t2)); + balances = new uint256[](3); + balances[0] = b0; + balances[1] = b1; + balances[2] = b2; + lastChangeBlock = lcb; + } +} diff --git a/tests/fixtures/MockBalancerVaultNoSlot.sol b/tests/fixtures/MockBalancerVaultNoSlot.sol new file mode 100644 index 0000000..6b7604f --- /dev/null +++ b/tests/fixtures/MockBalancerVaultNoSlot.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +/// Balancer-vault stub whose `getPoolTokens` returns a VALID, decodable +/// `(address[], uint256[], uint256)` tuple built entirely from literals — it is +/// `pure` and performs NO SLOAD. So `call_raw_with_access_list` (restricted to +/// the vault) captures an EMPTY `(vault, slot)` set, driving the planner's +/// `NoSlotsDiscovered` branch (decode succeeds, but nothing was warmed). Used to +/// pin that empty-capture repairs via re-discovery rather than a vault-wide +/// storage purge (the vault is a shared singleton). +contract MockBalancerVaultNoSlot { + function getPoolTokens(bytes32) + external + pure + returns (address[] memory tokens, uint256[] memory balances, uint256 lastChangeBlock) + { + tokens = new address[](2); + tokens[0] = address(uint160(0xC0)); + tokens[1] = address(uint160(0xC1)); + balances = new uint256[](2); + balances[0] = 100; + balances[1] = 200; + lastChangeBlock = 7; + } +} diff --git a/tests/fixtures/MockBalancerVaultQuote.sol b/tests/fixtures/MockBalancerVaultQuote.sol new file mode 100644 index 0000000..c34a81f --- /dev/null +++ b/tests/fixtures/MockBalancerVaultQuote.sol @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +/// Combined Balancer-vault stub for offline swap-sim + reactive tests. +/// +/// Serves BOTH cold-start discovery and a swap quote off the SAME fixed slots, +/// so the reactive `Swap` refresh of the discovered balance slots is observable +/// through a subsequent `queryBatchSwap`: +/// +/// - `getPoolTokens(bytes32)` SLOADs fixed slots 0..=4 (token0, token1, +/// balance0, balance1, lastChangeBlock) and returns the dynamic +/// `(address[] tokens, uint256[] balances, uint256 lastChangeBlock)` tuple — +/// identical shape to `MockBalancerVault`, so cold-start discovers the same +/// `(vault, slot)` set {0,1,2,3,4}. +/// - `queryBatchSwap(...)` SLOADs balance slot 2 and returns it as the NEGATIVE +/// `assetDeltas[1]` (the vault-paid-out tokenOut delta), with +/// `assetDeltas[0] = +amount` (the tokenIn the vault is owed). So +/// `simulate_swap` decodes `amount_out = balance0 (slot 2)`; refreshing slot 2 +/// changes the quote. Arguments other than the GIVEN_IN amount are ignored. +contract MockBalancerVaultQuote { + function getPoolTokens(bytes32) + external + view + returns (address[] memory tokens, uint256[] memory balances, uint256 lastChangeBlock) + { + uint256 t0; + uint256 t1; + uint256 b0; + uint256 b1; + uint256 lcb; + assembly { + t0 := sload(0) + t1 := sload(1) + b0 := sload(2) + b1 := sload(3) + lcb := sload(4) + } + tokens = new address[](2); + tokens[0] = address(uint160(t0)); + tokens[1] = address(uint160(t1)); + balances = new uint256[](2); + balances[0] = b0; + balances[1] = b1; + lastChangeBlock = lcb; + } + + struct BatchSwapStep { + bytes32 poolId; + uint256 assetInIndex; + uint256 assetOutIndex; + uint256 amount; + bytes userData; + } + + struct FundManagement { + address sender; + bool fromInternalBalance; + address recipient; + bool toInternalBalance; + } + + function queryBatchSwap( + uint8, + BatchSwapStep[] calldata swaps, + address[] calldata, + FundManagement calldata + ) external view returns (int256[] memory assetDeltas) { + uint256 b0; + assembly { + b0 := sload(2) + } + assetDeltas = new int256[](2); + // tokenIn delta: positive = owed to the vault (the GIVEN_IN amount). + assetDeltas[0] = int256(swaps[0].amount); + // tokenOut delta: negative = paid out by the vault (balance0 at slot 2). + assetDeltas[1] = -int256(b0); + } +} diff --git a/tests/fixtures/MockSolidlyPool.sol b/tests/fixtures/MockSolidlyPool.sol new file mode 100644 index 0000000..82b34f6 --- /dev/null +++ b/tests/fixtures/MockSolidlyPool.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +/// Minimal Solidly V2 (Aerodrome / Velodrome V2) pool stub for offline +/// swap-sim tests. `getAmountOut(amountIn, tokenIn)` returns a deterministic +/// value SLOADed from a fixed slot (slot 0), so a test can seed the expected +/// output and assert `simulate_swap` decodes the pool's own quote. The args are +/// ignored (the adapter only needs the selector + decode path exercised). +contract MockSolidlyPool { + function getAmountOut(uint256, address) external view returns (uint256 out) { + assembly { + out := sload(0) + } + } +} diff --git a/tests/fixtures/MockV2Router.sol b/tests/fixtures/MockV2Router.sol new file mode 100644 index 0000000..e46d900 --- /dev/null +++ b/tests/fixtures/MockV2Router.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +/// Minimal UniswapV2Router02 stub for offline swap-sim tests. +/// +/// `getAmountsOut` SLOADs a FIXED slot (0) at the router address — the warmed +/// "quote" slot the test seeds — and returns a 2-element `uint256[]` whose last +/// element is that value, so `simulate_swap` (which reads `amounts.last()`) +/// returns a deterministic, slot-derived amount fully offline. The arguments are +/// ignored: the planner is layout-agnostic for the offline harness. The DYNAMIC +/// `uint256[]` return matches the real `getAmountsOut(uint256,address[])` ABI. +contract MockV2Router { + function getAmountsOut(uint256, address[] calldata) + external + view + returns (uint256[] memory amounts) + { + uint256 out; + assembly { + out := sload(0) + } + amounts = new uint256[](2); + amounts[0] = 0; + amounts[1] = out; + } +} diff --git a/tests/fixtures/MockV3Quoter.sol b/tests/fixtures/MockV3Quoter.sol new file mode 100644 index 0000000..caa7ebe --- /dev/null +++ b/tests/fixtures/MockV3Quoter.sol @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +/// Minimal Uniswap V3 QuoterV2 stub for offline swap-sim tests. +/// +/// `quoteExactInputSingle` SLOADs a FIXED slot (0) at the quoter address — the +/// warmed "quote" slot the test seeds — and returns it as `amountOut`, with the +/// auxiliary fields zeroed, so `simulate_swap` returns a deterministic, +/// slot-derived amount fully offline. The struct argument is ignored. The +/// return tuple matches the real `QuoterV2.quoteExactInputSingle` shape +/// `(uint256 amountOut, uint160, uint32, uint256)`. +contract MockV3Quoter { + struct QuoteExactInputSingleParams { + address tokenIn; + address tokenOut; + uint256 amountIn; + uint24 fee; + uint160 sqrtPriceLimitX96; + } + + function quoteExactInputSingle(QuoteExactInputSingleParams calldata) + external + view + returns ( + uint256 amountOut, + uint160 sqrtPriceX96After, + uint32 initializedTicksCrossed, + uint256 gasEstimate + ) + { + uint256 out; + assembly { + out := sload(0) + } + amountOut = out; + sqrtPriceX96After = 0; + initializedTicksCrossed = 0; + gasEstimate = 0; + } +} diff --git a/tests/fixtures/mock_balancer_vault_3_runtime.hex b/tests/fixtures/mock_balancer_vault_3_runtime.hex new file mode 100644 index 0000000..ec33f09 --- /dev/null +++ b/tests/fixtures/mock_balancer_vault_3_runtime.hex @@ -0,0 +1 @@ +608060405234801561000f575f80fd5b5060043610610029575f3560e01c8063f94d46681461002d575b5f80fd5b61004061003b3660046101bb565b610058565b60405161004f939291906101d2565b60405180910390f35b5f80546001546002546003805460045460055460065460408051868152608081019091526060998a9990989097909690959493929190602082018b80368337019050509950868a5f815181106100b0576100b061025d565b60200260200101906001600160a01b031690816001600160a01b031681525050858a6001815181106100e4576100e461025d565b60200260200101906001600160a01b031690816001600160a01b031681525050848a6002815181106101185761011861025d565b6001600160a01b03929092166020928302919091018201526040805160038082526080820190925291820160608036833701905050985083895f815181106101625761016261025d565b60200260200101818152505082896001815181106101825761018261025d565b60200260200101818152505081896002815181106101a2576101a261025d565b6020908102919091010152989a97995050505050505050565b5f602082840312156101cb575f80fd5b5035919050565b606080825284519082018190525f906020906080840190828801845b828110156102135781516001600160a01b0316845292840192908401906001016101ee565b505050838103828501528551808252868301918301905f5b818110156102475783518352928401929184019160010161022b565b5050809350505050826040830152949350505050565b634e487b7160e01b5f52603260045260245ffdfea264697066735822122023df74ee5a715739c9c7bb148f4e89579ed58fdbe20a1128dfb58315c9c8139a64736f6c63430008170033 \ No newline at end of file diff --git a/tests/fixtures/mock_balancer_vault_noslot_runtime.hex b/tests/fixtures/mock_balancer_vault_noslot_runtime.hex new file mode 100644 index 0000000..eb2dfae --- /dev/null +++ b/tests/fixtures/mock_balancer_vault_noslot_runtime.hex @@ -0,0 +1 @@ +608060405234801561000f575f80fd5b5060043610610029575f3560e01c8063f94d46681461002d575b5f80fd5b61004061003b36600461014d565b610058565b60405161004f93929190610164565b60405180910390f35b604080516002808252606082810190935282915f9181602001602082028036833701905050925060c0835f81518110610093576100936101ef565b60200260200101906001600160a01b031690816001600160a01b03168152505060c1836001815181106100c8576100c86101ef565b6001600160a01b03929092166020928302919091018201526040805160028082526060820183529092909190830190803683370190505091506064825f81518110610115576101156101ef565b60200260200101818152505060c882600181518110610136576101366101ef565b602002602001018181525050600790509193909250565b5f6020828403121561015d575f80fd5b5035919050565b606080825284519082018190525f906020906080840190828801845b828110156101a55781516001600160a01b031684529284019290840190600101610180565b505050838103828501528551808252868301918301905f5b818110156101d9578351835292840192918401916001016101bd565b5050809350505050826040830152949350505050565b634e487b7160e01b5f52603260045260245ffdfea264697066735822122065916fcef857476341568ddeb99b246273b8ec7d7d0f4e8e5518a3f70c05afad64736f6c63430008170033 \ No newline at end of file diff --git a/tests/fixtures/mock_balancer_vault_quote_runtime.hex b/tests/fixtures/mock_balancer_vault_quote_runtime.hex new file mode 100644 index 0000000..f9b41f5 --- /dev/null +++ b/tests/fixtures/mock_balancer_vault_quote_runtime.hex @@ -0,0 +1 @@ +608060405234801561000f575f80fd5b5060043610610034575f3560e01c8063f84d066e14610038578063f94d466814610061575b5f80fd5b61004b61004636600461026a565b610083565b6040516100589190610305565b60405180910390f35b61007461006f366004610348565b61011f565b6040516100589392919061035f565b60028054604080518381526060808201835293909160208301908036833701905050915086865f8181106100b9576100b96103ea565b90506020028101906100cb91906103fe565b60600135825f815181106100e1576100e16103ea565b60209081029190910101526100f58161041c565b82600181518110610108576101086103ea565b602002602001018181525050509695505050505050565b5f805460015460028054600354600454604080518581526060808201835298899890979096909594939260208301908036833701905050975084885f8151811061016b5761016b6103ea565b60200260200101906001600160a01b031690816001600160a01b031681525050838860018151811061019f5761019f6103ea565b6001600160a01b039290921660209283029190910182015260408051600280825260608201835290929091908301908036833701905050965082875f815181106101eb576101eb6103ea565b602002602001018181525050818760018151811061020b5761020b6103ea565b602090810291909101015296989597505050505050565b5f8083601f840112610232575f80fd5b50813567ffffffffffffffff811115610249575f80fd5b6020830191508360208260051b8501011115610263575f80fd5b9250929050565b5f805f805f8086880360e0811215610280575f80fd5b873560ff81168114610290575f80fd5b9650602088013567ffffffffffffffff808211156102ac575f80fd5b6102b88b838c01610222565b909850965060408a01359150808211156102d0575f80fd5b506102dd8a828b01610222565b9095509350506080605f19820112156102f4575f80fd5b506060870190509295509295509295565b602080825282518282018190525f9190848201906040850190845b8181101561033c57835183529284019291840191600101610320565b50909695505050505050565b5f60208284031215610358575f80fd5b5035919050565b606080825284519082018190525f906020906080840190828801845b828110156103a05781516001600160a01b03168452928401929084019060010161037b565b505050838103828501528551808252868301918301905f5b818110156103d4578351835292840192918401916001016103b8565b5050809350505050826040830152949350505050565b634e487b7160e01b5f52603260045260245ffd5b5f8235609e19833603018112610412575f80fd5b9190910192915050565b5f600160ff1b820161043c57634e487b7160e01b5f52601160045260245ffd5b505f039056fea264697066735822122070cc947b5ad3a8efdd672d60da03455fe00a72b0f5c30ea0782d450c960db0c864736f6c63430008170033 diff --git a/tests/fixtures/mock_balancer_vault_revert_runtime.hex b/tests/fixtures/mock_balancer_vault_revert_runtime.hex new file mode 100644 index 0000000..e8a9854 --- /dev/null +++ b/tests/fixtures/mock_balancer_vault_revert_runtime.hex @@ -0,0 +1 @@ +60006000fd \ No newline at end of file diff --git a/tests/fixtures/mock_balancer_vault_runtime.hex b/tests/fixtures/mock_balancer_vault_runtime.hex new file mode 100644 index 0000000..52e7c48 --- /dev/null +++ b/tests/fixtures/mock_balancer_vault_runtime.hex @@ -0,0 +1 @@ +608060405234801561000f575f80fd5b5060043610610029575f3560e01c8063f94d46681461002d575b5f80fd5b61004061003b36600461015b565b610058565b60405161004f93929190610172565b60405180910390f35b5f805460015460028054600354600454604080518581526060808201835298899890979096909594939260208301908036833701905050975084885f815181106100a4576100a46101fd565b60200260200101906001600160a01b031690816001600160a01b03168152505083886001815181106100d8576100d86101fd565b6001600160a01b039290921660209283029190910182015260408051600280825260608201835290929091908301908036833701905050965082875f81518110610124576101246101fd565b6020026020010181815250508187600181518110610144576101446101fd565b602090810291909101015296989597505050505050565b5f6020828403121561016b575f80fd5b5035919050565b606080825284519082018190525f906020906080840190828801845b828110156101b35781516001600160a01b03168452928401929084019060010161018e565b505050838103828501528551808252868301918301905f5b818110156101e7578351835292840192918401916001016101cb565b5050809350505050826040830152949350505050565b634e487b7160e01b5f52603260045260245ffdfea26469706673582212205374b2613f4fab66ba931635aa8b4e774ab926ea76946b5363f4dff9ec078f8464736f6c63430008170033 \ No newline at end of file diff --git a/tests/fixtures/mock_solidly_pool_runtime.hex b/tests/fixtures/mock_solidly_pool_runtime.hex new file mode 100644 index 0000000..0d9daff --- /dev/null +++ b/tests/fixtures/mock_solidly_pool_runtime.hex @@ -0,0 +1 @@ +6080604052348015600e575f80fd5b50600436106026575f3560e01c8063f140a35a14602a575b5f80fd5b603c6035366004604e565b50505f5490565b60405190815260200160405180910390f35b5f8060408385031215605e575f80fd5b8235915060208301356001600160a01b0381168114607a575f80fd5b80915050925092905056fea264697066735822122061bfedced32c45a88c54df70192bfbb64cf2fd3e9b0dc3031d15d29b5edf022964736f6c63430008170033 \ No newline at end of file diff --git a/tests/fixtures/mock_v2_router_runtime.hex b/tests/fixtures/mock_v2_router_runtime.hex new file mode 100644 index 0000000..a6a4f55 --- /dev/null +++ b/tests/fixtures/mock_v2_router_runtime.hex @@ -0,0 +1 @@ +608060405234801561000f575f80fd5b5060043610610029575f3560e01c8063d06ca61f1461002d575b5f80fd5b61004061003b3660046100c0565b610056565b60405161004d9190610138565b60405180910390f35b5f546040805160028082526060808301845293926020830190803683370190505091505f825f8151811061008c5761008c61017b565b60200260200101818152505080826001815181106100ac576100ac61017b565b602002602001018181525050509392505050565b5f805f604084860312156100d2575f80fd5b83359250602084013567ffffffffffffffff808211156100f0575f80fd5b818601915086601f830112610103575f80fd5b813581811115610111575f80fd5b8760208260051b8501011115610125575f80fd5b6020830194508093505050509250925092565b602080825282518282018190525f9190848201906040850190845b8181101561016f57835183529284019291840191600101610153565b50909695505050505050565b634e487b7160e01b5f52603260045260245ffdfea2646970667358221220ad90884507ea5df8eb8d40213566ba2cec1035d236f3ae95c53ed57d252c9d7964736f6c63430008170033 diff --git a/tests/fixtures/mock_v3_quoter_runtime.hex b/tests/fixtures/mock_v3_quoter_runtime.hex new file mode 100644 index 0000000..ea8ccd1 --- /dev/null +++ b/tests/fixtures/mock_v3_quoter_runtime.hex @@ -0,0 +1 @@ +6080604052348015600e575f80fd5b50600436106026575f3560e01c8063c6a5026a14602a575b5f80fd5b604060353660046075565b505f80549181908190565b604080519485526001600160a01b0393909316602085015263ffffffff91909116838301526060830152519081900360800190f35b5f60a082840312156084575f80fd5b5091905056fea2646970667358221220daaebff3824e8a45b18c7284b02e2ee45b111eb54eea31419a153b86ea4b029264736f6c63430008170033 diff --git a/tests/pipeline_e2e.rs b/tests/pipeline_e2e.rs new file mode 100644 index 0000000..125bd4d --- /dev/null +++ b/tests/pipeline_e2e.rs @@ -0,0 +1,411 @@ +//! End-to-end pipeline tests on the adapters path: register → cold-start +//! (snapshot) → reactive event → cache mutation, composed on a SHARED +//! `EvmCache` + registry. The existing `adapter_reactive.rs` covers reactive +//! apply in isolation and `cold_start_adoption.rs` covers cold-start in +//! isolation; this file pins that they compose — the gap the readiness audit +//! flagged. It also includes full register→cold-start→react→simulate pipeline +//! tests for V2 and V3 (Balancer's full chain lives in `adapter_swap_sim.rs`). + +use std::collections::HashMap; +use std::sync::Arc; + +use alloy_eips::BlockId; +use alloy_network::Ethereum; +use alloy_primitives::{Address, B256, Bytes, Log as PrimitiveLog, U256, hex, keccak256}; +use alloy_provider::{RootProvider, network::AnyNetwork}; +use alloy_rpc_client::RpcClient; +use alloy_rpc_types_eth::Log as RpcLog; +use alloy_transport::mock::Asserter; +use anyhow::{Result, anyhow}; +use evm_amm_state::adapters::storage::{ + V2_RESERVES_SLOT, V2_TOKEN0_SLOT, V2_TOKEN1_SLOT, V3_LIQUIDITY_SLOT, V3_SLOT0_SLOT, + V3StorageLayout, +}; +use evm_amm_state::adapters::{ + AdapterRegistry, AmmAdapter, AmmReactiveHandler, ColdStartOutcome, ColdStartPolicy, PoolKey, + PoolRegistration, PoolStatus, ProtocolMetadata, SimConfig, UniswapV2Adapter, UniswapV2Metadata, + UniswapV3Adapter, V3Metadata, +}; +use evm_fork_cache::cache::{EvmCache, StorageBatchFetchFn}; +use evm_fork_cache::reactive::{ + BlockRef, ChainStatus, InputSource, ReactiveConfig, ReactiveContext, ReactiveInput, + ReactiveInputBatch, ReactiveInputRecord, ReactiveRuntime, +}; +use revm::state::{AccountInfo, Bytecode}; + +// --- harness (mirrors tests/adapter_reactive.rs conventions) --- + +fn block_hash(block_number: u64) -> B256 { + B256::repeat_byte(block_number as u8) +} + +fn rpc_log(address: Address, topics: Vec, data: Vec, block_number: u64) -> RpcLog { + RpcLog { + inner: PrimitiveLog::new_unchecked(address, topics, Bytes::from(data)), + block_hash: Some(block_hash(block_number)), + block_number: Some(block_number), + block_timestamp: Some(1_700_000_000 + block_number), + transaction_hash: Some(B256::repeat_byte(1)), + transaction_index: Some(0), + log_index: Some(0), + removed: false, + } +} + +fn included_context(block_number: u64) -> ReactiveContext { + let block = BlockRef { + number: block_number, + hash: block_hash(block_number), + parent_hash: Some(block_hash(block_number.saturating_sub(1))), + timestamp: Some(1_700_000_000 + block_number), + }; + ReactiveContext { + chain_id: Some(1), + source: InputSource::Synthetic, + chain_status: ChainStatus::Included { + block: block.clone(), + confirmations: 0, + }, + block: Some(block), + transaction_index: Some(0), + log_index: Some(0), + } +} + +fn batch(log: RpcLog, block_number: u64) -> ReactiveInputBatch { + ReactiveInputBatch::new(vec![ReactiveInputRecord::new( + ReactiveInput::Log(log), + included_context(block_number), + )]) +} + +async fn setup_cache() -> Result { + let asserter = Asserter::new(); + let client = RpcClient::mocked(asserter); + let provider = RootProvider::::new(client); + Ok(EvmCache::new(Arc::new(provider)).await) +} + +fn stub_fetcher(values: HashMap<(Address, U256), U256>) -> StorageBatchFetchFn { + Arc::new( + move |requests: Vec<(Address, U256)>, _block: Option| { + requests + .into_iter() + .map(|(address, slot)| { + let value = values.get(&(address, slot)).copied().unwrap_or_default(); + (address, slot, Ok(value)) + }) + .collect() + }, + ) +} + +fn v2_sync_topic() -> B256 { + keccak256("Sync(uint112,uint112)") +} + +fn token_word(address: Address) -> U256 { + U256::from_be_slice(address.as_slice()) +} + +fn abi_words(values: impl IntoIterator) -> Vec { + values + .into_iter() + .flat_map(|v| v.to_be_bytes::<32>().to_vec()) + .collect() +} + +fn low_mask(bits: usize) -> U256 { + (U256::from(1) << bits) - U256::from(1) +} + +/// Cold-start warms a V2 pool's reserves slot to its on-chain value, then a +/// reactive `Sync` event mutates that SAME warmed slot in the SAME cache. +#[tokio::test(flavor = "multi_thread")] +async fn v2_cold_start_then_reactive_sync_updates_warmed_reserves() -> Result<()> { + let pool = Address::repeat_byte(0x91); + let token0 = Address::repeat_byte(0xa0); + let token1 = Address::repeat_byte(0xa1); + + // On-chain reserves the cold-start snapshot warms (timestamp in the top 32 bits). + let cs_reserve0 = U256::from(1_000_u64); + let cs_reserve1 = U256::from(2_000_u64); + let cs_timestamp = U256::from(0x1234_u64); + let cs_slot = cs_reserve0 | (cs_reserve1 << 112) | (cs_timestamp << 224); + + let adapter = Arc::new(UniswapV2Adapter::default()); + let mut registry = AdapterRegistry::new(); + registry.register_adapter(adapter.clone()).unwrap(); + + let mut cache = setup_cache().await?; + cache.set_storage_batch_fetcher(stub_fetcher(HashMap::from([ + ((pool, V2_RESERVES_SLOT), cs_slot), + ((pool, V2_TOKEN0_SLOT), token_word(token0)), + ((pool, V2_TOKEN1_SLOT), token_word(token1)), + ]))); + + // 1) Cold-start (snapshot) on an external registration. + let mut registration = PoolRegistration::new(PoolKey::UniswapV2(pool)).with_state_address(pool); + let outcome = registry.cold_start(&mut registration, &mut cache, ColdStartPolicy::Eager)?; + assert!( + matches!(outcome, ColdStartOutcome::Ready(_)), + "cold-start should reach Ready, got {outcome:?}" + ); + assert_eq!(registration.status, PoolStatus::Ready); + assert_eq!( + cache.cached_storage_value(pool, V2_RESERVES_SLOT), + Some(cs_slot), + "cold-start must warm the reserves slot to the on-chain value" + ); + + // 2) Register the cold-started pool for reactive routing and ingest a Sync + // carrying NEW reserves on the SAME cache + registry. + let sources = adapter.event_sources(®istration); + registry + .register_pool(registration.with_event_sources(sources)) + .unwrap(); + + let new_reserve0 = U256::from(1_500_u64); + let new_reserve1 = U256::from(2_500_u64); + let log = rpc_log( + pool, + vec![v2_sync_topic()], + abi_words([new_reserve0, new_reserve1]), + 12, + ); + + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + runtime.register_handler(Arc::new(AmmReactiveHandler::new(registry)))?; + let report = runtime.ingest_batch(&mut cache, batch(log, 12))?; + assert_eq!(report.applied.len(), 1, "the Sync must apply exactly once"); + + // 3) The cold-start-warmed slot now reflects the reactive event: low 224 bits + // overwritten with the new reserves, the timestamp high bits preserved. + let raw = cache + .cached_storage_value(pool, V2_RESERVES_SLOT) + .expect("reserves slot is warm"); + assert_eq!( + raw & low_mask(112), + new_reserve0, + "reserve0 updated by Sync" + ); + assert_eq!( + (raw >> 112) & low_mask(112), + new_reserve1, + "reserve1 updated by Sync" + ); + assert_eq!( + raw >> 224, + cs_timestamp, + "the masked Sync update preserves the cold-start timestamp bits" + ); + Ok(()) +} + +// --- Full pipeline (register → cold-start → react → simulate), offline --- +// +// These exercise all three legs in one flow as CI-runnable regression coverage. +// The mock quote contract returns a seeded value (not a reserves-derived +// computation), so these prove the chain WIRES and runs end-to-end without +// error; the state-vs-quote correctness is proven separately by the RPC-parity +// and live-WebSocket tests. (Balancer's full chain — cold-start → Swap refresh → +// re-simulate — already lives in `tests/adapter_swap_sim.rs`.) + +fn install_default_account(cache: &mut EvmCache, addr: Address) { + cache + .db_mut() + .insert_account_info(addr, AccountInfo::default()); +} + +/// Install raw runtime bytecode (a compiled mock quote fixture) at `addr`. +fn install_mock_runtime(cache: &mut EvmCache, addr: Address, runtime: &str) { + let code = Bytecode::new_raw(Bytes::from( + hex::decode(runtime.trim()).expect("valid mock runtime hex"), + )); + let code_hash = code.hash_slow(); + cache.db_mut().insert_account_info( + addr, + AccountInfo { + balance: U256::ZERO, + nonce: 1, + code: Some(code), + code_hash, + account_id: None, + }, + ); +} + +fn topic_address(address: Address) -> B256 { + let mut bytes = [0u8; 32]; + bytes[12..].copy_from_slice(address.as_slice()); + B256::from(bytes) +} + +fn v3_swap_topic() -> B256 { + keccak256("Swap(address,address,int256,int256,uint160,uint128,int24)") +} + +#[tokio::test(flavor = "multi_thread")] +async fn v2_full_pipeline_cold_start_react_simulate() -> Result<()> { + let pool = Address::repeat_byte(0x92); + let router = Address::repeat_byte(0xb1); + let token0 = Address::repeat_byte(0xa0); + let token1 = Address::repeat_byte(0xa1); + let quote_out = U256::from(4_242_u64); + + let mut cache = setup_cache().await?; + install_default_account(&mut cache, Address::ZERO); + // Mock router: getAmountsOut returns sload(0); seed it. + install_mock_runtime( + &mut cache, + router, + include_str!("fixtures/mock_v2_router_runtime.hex"), + ); + cache + .db_mut() + .insert_account_storage(router, U256::ZERO, quote_out)?; + let cs_slot = U256::from(1_000_u64) | (U256::from(2_000_u64) << 112); + cache.set_storage_batch_fetcher(stub_fetcher(HashMap::from([ + ((pool, V2_RESERVES_SLOT), cs_slot), + ((pool, V2_TOKEN0_SLOT), token_word(token0)), + ((pool, V2_TOKEN1_SLOT), token_word(token1)), + ]))); + + let adapter = UniswapV2Adapter::default(); + let mut registry = AdapterRegistry::new(); + registry.register_adapter(Arc::new(UniswapV2Adapter::default()))?; + let mut registration = PoolRegistration::new(PoolKey::UniswapV2(pool)) + .with_state_address(pool) + .with_metadata(ProtocolMetadata::UniswapV2(UniswapV2Metadata { + token0: Some(token0), + token1: Some(token1), + fee_bps: Some(30), + })); + + // 1) cold-start + assert!(matches!( + registry.cold_start(&mut registration, &mut cache, ColdStartPolicy::Eager)?, + ColdStartOutcome::Ready(_) + )); + + // 2) react: a Sync updates the warmed reserves slot + let sources = adapter.event_sources(®istration); + registry.register_pool(registration.clone().with_event_sources(sources))?; + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + runtime.register_handler(Arc::new(AmmReactiveHandler::new(registry)))?; + let new_r0 = U256::from(1_500_u64); + let new_r1 = U256::from(2_500_u64); + let log = rpc_log(pool, vec![v2_sync_topic()], abi_words([new_r0, new_r1]), 12); + runtime.ingest_batch(&mut cache, batch(log, 12))?; + let raw = cache + .cached_storage_value(pool, V2_RESERVES_SLOT) + .expect("reserves warm"); + assert_eq!(raw & low_mask(112), new_r0, "react leg updated reserve0"); + + // 3) simulate against the post-event state + let config = SimConfig::default().with_v2_router(router); + let quote = adapter + .simulate_swap( + ®istration, + &mut cache, + token0, + token1, + U256::from(1_000_u64), + &config, + ) + .map_err(|e| anyhow!("v2 sim failed: {e}"))?; + assert_eq!(quote.amount_out, quote_out, "simulate leg returned a quote"); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn v3_full_pipeline_cold_start_react_simulate() -> Result<()> { + let pool = Address::repeat_byte(0x93); + let quoter = Address::repeat_byte(0xb2); + let token0 = Address::repeat_byte(0xa2); + let token1 = Address::repeat_byte(0xa3); + let quote_out = U256::from(9_999_u64); + + let mut cache = setup_cache().await?; + install_default_account(&mut cache, Address::ZERO); + install_mock_runtime( + &mut cache, + quoter, + include_str!("fixtures/mock_v3_quoter_runtime.hex"), + ); + cache + .db_mut() + .insert_account_storage(quoter, U256::ZERO, quote_out)?; + // cold-start warms slot0 (non-zero -> Ready) + liquidity. + cache.set_storage_batch_fetcher(stub_fetcher(HashMap::from([ + ((pool, V3_SLOT0_SLOT), U256::from(123_456_u64)), + ((pool, V3_LIQUIDITY_SLOT), U256::from(67_890_u64)), + ]))); + + let adapter = UniswapV3Adapter::default(); + let mut registry = AdapterRegistry::new(); + registry.register_adapter(Arc::new(UniswapV3Adapter::default()))?; + let mut registration = PoolRegistration::new(PoolKey::UniswapV3(pool)) + .with_state_address(pool) + .with_metadata(ProtocolMetadata::UniswapV3(V3Metadata { + token0: Some(token0), + token1: Some(token1), + fee: Some(500), + tick_spacing: Some(10), + storage_layout: Some(V3StorageLayout::uniswap(10)), + })); + + // 1) cold-start + assert!(matches!( + registry.cold_start(&mut registration, &mut cache, ColdStartPolicy::Eager)?, + ColdStartOutcome::Ready(_) + )); + + // 2) react: a Swap updates slot0 (sqrtPrice/tick) + liquidity + let sources = adapter.event_sources(®istration); + registry.register_pool(registration.clone().with_event_sources(sources))?; + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + runtime.register_handler(Arc::new(AmmReactiveHandler::new(registry)))?; + let new_sqrt = U256::from(54_321_u64); + let new_liq = U256::from(11_111_u64); + let new_tick = U256::from(7_u64); + let log = rpc_log( + pool, + vec![ + v3_swap_topic(), + topic_address(Address::repeat_byte(0x01)), + topic_address(Address::repeat_byte(0x02)), + ], + abi_words([U256::ZERO, U256::ZERO, new_sqrt, new_liq, new_tick]), + 12, + ); + runtime.ingest_batch(&mut cache, batch(log, 12))?; + let raw_slot0 = cache + .cached_storage_value(pool, V3_SLOT0_SLOT) + .expect("slot0 warm"); + assert_eq!( + raw_slot0 & low_mask(160), + new_sqrt, + "react leg updated sqrtPrice" + ); + assert_eq!( + cache.cached_storage_value(pool, V3_LIQUIDITY_SLOT), + Some(new_liq), + "react leg updated liquidity" + ); + + // 3) simulate against the post-event state + let config = SimConfig::default().with_v3_quoter(quoter); + let quote = adapter + .simulate_swap( + ®istration, + &mut cache, + token0, + token1, + U256::from(1_000_u64), + &config, + ) + .map_err(|e| anyhow!("v3 sim failed: {e}"))?; + assert_eq!(quote.amount_out, quote_out, "simulate leg returned a quote"); + Ok(()) +} diff --git a/tests/reactive_ws_e2e.rs b/tests/reactive_ws_e2e.rs new file mode 100644 index 0000000..6add8c8 --- /dev/null +++ b/tests/reactive_ws_e2e.rs @@ -0,0 +1,329 @@ +//! Live WebSocket reactive E2E (env-gated, `#[ignore]`) — MANAGER RUNS THIS. +//! +//! Proves the NEW (adapters) pipeline keeps pool state correct using ONLY +//! WebSocket log events — never re-querying storage. Flow: +//! 1. Pin a fork at `B0` (latest at start) and cold-start a busy Uniswap V2 +//! pool (warms reserves + token slots at `B0`). +//! 2. For ~5 minutes, apply ONLY `Sync` events arriving over a `wss://` +//! subscription, through the real reactive runtime (`ingest_batch` → +//! masked write of the new reserves). No storage refetch. +//! 3. Assert the event-synced reserves match on-chain at the last event's +//! block `N`, and `simulate_swap` matches the on-chain `getAmountsOut` +//! quote at `N`. +//! +//! No-cheat discriminator: the cache backend is pinned at `B0`, so any sneaky +//! refetch would yield the STALE `B0` reserves. We assert the sim does NOT equal +//! the `B0` quote, and (when the chain has moved) does NOT equal the live-head +//! `M` quote — so the only way it matches `N` is by reading the event-sourced +//! state. +//! +//! Uniswap V2 is used because its `Sync` event carries the EXACT new reserves, +//! making the masked write pure event-sourcing with zero refetch. (V3 +//! tick-crossing and Balancer's refresh-on-event are not "events-only" by +//! construction, so they are out of scope for this specific claim.) +//! +//! Run (derives `wss://` from `E2E_RPC_URL`; default 300s, override +//! `E2E_WS_SECONDS`): +//! ```text +//! E2E_RPC_URL= cargo test --test reactive_ws_e2e -- --ignored --nocapture +//! ``` + +use std::sync::Arc; +use std::time::Duration; + +use alloy_eips::{BlockId, BlockNumberOrTag}; +use alloy_network::{AnyNetwork, Ethereum, TransactionBuilder}; +use alloy_primitives::{Address, Bytes, U256, address, keccak256}; +use alloy_provider::{Provider, RootProvider}; +use alloy_rpc_types_eth::{Filter, Log as RpcLog, TransactionRequest}; +use alloy_sol_types::SolCall; +use anyhow::{Context, Result, anyhow}; +use evm_amm_state::adapters::sim::getAmountsOutCall; +use evm_amm_state::adapters::storage::V2_RESERVES_SLOT; +use evm_amm_state::adapters::{ + AdapterRegistry, AmmAdapter, AmmReactiveHandler, ColdStartPolicy, PoolKey, PoolRegistration, + ProtocolMetadata, SimConfig, UniswapV2Adapter, UniswapV2Metadata, +}; +use evm_fork_cache::cache::EvmCache; +use evm_fork_cache::reactive::{ + BlockRef, ChainStatus, InputSource, ReactiveConfig, ReactiveContext, ReactiveInput, + ReactiveInputBatch, ReactiveInputRecord, ReactiveRuntime, +}; +use futures::StreamExt; + +const USDC: Address = address!("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"); +const WETH: Address = address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"); +const V2_ROUTER_02: Address = address!("7a250d5630B4cF539739dF2C5dAcb4c659F2488D"); +const V2_USDC_WETH_PAIR: Address = address!("B4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc"); + +fn mask112() -> U256 { + (U256::from(1) << 112) - U256::from(1) +} + +/// The cached `(reserve0, reserve1)` for the pair (low 224 bits of the packed +/// reserves slot; the top 32 timestamp bits are ignored — `Sync` does not carry +/// a timestamp, so the masked write intentionally leaves them at the cold-start +/// value, and `getAmountsOut` does not depend on them). +fn cached_reserves(cache: &EvmCache) -> (U256, U256) { + let raw = cache + .cached_storage_value(V2_USDC_WETH_PAIR, V2_RESERVES_SLOT) + .unwrap_or_default(); + (raw & mask112(), (raw >> 112) & mask112()) +} + +fn ctx_from_log(log: &RpcLog) -> ReactiveContext { + let number = log.block_number.unwrap_or_default(); + let hash = log.block_hash.unwrap_or_default(); + let block = BlockRef { + number, + hash, + parent_hash: None, + timestamp: log.block_timestamp, + }; + ReactiveContext { + chain_id: Some(1), + source: InputSource::Synthetic, + chain_status: ChainStatus::Included { + block: block.clone(), + confirmations: 0, + }, + block: Some(block), + transaction_index: log.transaction_index, + log_index: log.log_index, + } +} + +/// On-chain `Router02.getAmountsOut(amountIn, [USDC, WETH])` at `block` (ground +/// truth). +async fn amounts_out_at( + provider: &RootProvider, + block: u64, + amount_in: U256, +) -> Result { + let calldata = Bytes::from( + getAmountsOutCall { + amountIn: amount_in, + path: vec![USDC, WETH], + } + .abi_encode(), + ); + let tx = TransactionRequest::default() + .with_to(V2_ROUTER_02) + .with_input(calldata); + let out = provider + .call(tx.into()) + .block(BlockId::Number(BlockNumberOrTag::Number(block))) + .await + .with_context(|| format!("eth_call getAmountsOut at block {block}"))?; + let amounts = getAmountsOutCall::abi_decode_returns_validate(&out)?; + Ok(*amounts.last().ok_or_else(|| anyhow!("empty amounts"))?) +} + +#[tokio::test(flavor = "multi_thread")] +#[ignore = "live WS subscription against E2E_RPC_URL; run with --ignored --nocapture"] +async fn ws_v2_reactive_sync_keeps_state_for_accurate_sim() -> Result<()> { + let Ok(rpc) = std::env::var("E2E_RPC_URL") else { + eprintln!("E2E_RPC_URL unset; skipping"); + return Ok(()); + }; + let ws_url = rpc + .replacen("https://", "wss://", 1) + .replacen("http://", "ws://", 1); + let secs: u64 = std::env::var("E2E_WS_SECONDS") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(300); + + let provider = Arc::new( + RootProvider::::connect(&ws_url) + .await + .context("connect wss:// (derived from E2E_RPC_URL)")?, + ); + + let b0 = provider.get_block_number().await.context("latest block")?; + eprintln!( + "[ws-e2e] pinned fork at B0={b0}; cold-starting V2 USDC/WETH and collecting Sync for {secs}s" + ); + + // 1. Pin the cache backend at B0 and cold-start the pair. + let mut cache = EvmCache::at_block( + provider.clone(), + BlockId::Number(BlockNumberOrTag::Number(b0)), + ) + .await; + + let mut registration = PoolRegistration::new(PoolKey::UniswapV2(V2_USDC_WETH_PAIR)) + .with_state_address(V2_USDC_WETH_PAIR) + .with_metadata(ProtocolMetadata::UniswapV2(UniswapV2Metadata { + token0: Some(USDC), + token1: Some(WETH), + fee_bps: Some(30), + })); + { + let mut cold = AdapterRegistry::new(); + cold.register_adapter(Arc::new(UniswapV2Adapter::default()))?; + cold.cold_start(&mut registration, &mut cache, ColdStartPolicy::Eager)?; + } + let r0 = cached_reserves(&cache); + eprintln!("[ws-e2e] B0 reserves: ({}, {})", r0.0, r0.1); + + // 2. Reactive runtime with the pair registered; subscribe to its Sync logs. + let adapter = UniswapV2Adapter::default(); + let sources = adapter.event_sources(®istration); + let mut registry = AdapterRegistry::new(); + registry.register_adapter(Arc::new(UniswapV2Adapter::default()))?; + registry.register_pool(registration.clone().with_event_sources(sources))?; + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + runtime.register_handler(Arc::new(AmmReactiveHandler::new(registry)))?; + + // Subscribe TOPIC-ONLY (all Uniswap-V2 `Sync`). This provider does not + // reliably push ADDRESS-filtered log subscriptions (an address+topic + // subscription delivered 0 in 5 min while topic-only delivered 42 in 45s), + // and topic-only is also higher-rate so the window reliably contains our + // pair's Syncs. The reactive handler routes each log by address, so ONLY the + // registered USDC/WETH pair's Syncs are applied — everything else is ignored. + let sync_topic = keccak256("Sync(uint112,uint112)"); + let filter = Filter::new().event_signature(sync_topic); + let mut stream = provider + .subscribe_logs(&filter) + .await + .context("subscribe_logs (needs a wss endpoint)")? + .into_stream(); + + let mut applied = 0u64; + let mut last_block = b0; + let deadline = tokio::time::Instant::now() + Duration::from_secs(secs); + loop { + tokio::select! { + _ = tokio::time::sleep_until(deadline) => break, + maybe_log = stream.next() => { + let Some(log) = maybe_log else { break }; + let block_n = log.block_number.unwrap_or(last_block); + let ctx = ctx_from_log(&log); + let batch = ReactiveInputBatch::new(vec![ReactiveInputRecord::new( + ReactiveInput::Log(log), + ctx, + )]); + let report = runtime.ingest_batch(&mut cache, batch)?; + if !report.applied.is_empty() { + applied += 1; + last_block = block_n; + if applied.is_multiple_of(5) { + let r = cached_reserves(&cache); + eprintln!("[ws-e2e] applied {applied} Syncs; block {last_block}; reserves ({}, {})", r.0, r.1); + } + } + } + } + } + eprintln!( + "[ws-e2e] window done: {applied} Sync events applied; last event block N={last_block}" + ); + + // 3. Assertions. + assert!( + applied > 0, + "no Sync events arrived in {secs}s — pool inactive or WS not delivering" + ); + let r_event = cached_reserves(&cache); + eprintln!( + "[ws-e2e] event-synced reserves at N={last_block}: ({}, {})", + r_event.0, r_event.1 + ); + assert!( + r_event != r0, + "reserves never changed during the window — inconclusive (need on-chain swaps)" + ); + + let amount_in = U256::from(1_000_000_u64); // 1 USDC + let config = SimConfig::default().with_v2_router(V2_ROUTER_02); + let sim = adapter + .simulate_swap(®istration, &mut cache, USDC, WETH, amount_in, &config) + .map_err(|e| anyhow!("simulate_swap failed: {e}"))?; + + // PRIMARY: sim over event-synced state == on-chain getAmountsOut at block N. + let q_n = amounts_out_at(&provider, last_block, amount_in).await?; + eprintln!("[ws-e2e] sim={} | eth_call@N={}", sim.amount_out, q_n); + assert_eq!( + sim.amount_out, q_n, + "sim must match on-chain getAmountsOut at the last event block N" + ); + + // NO-CHEAT 1: sim must NOT equal the pinned cold-start (B0) quote. The + // backend is pinned at B0, so a sneaky refetch would yield B0 reserves; + // since reserves changed, matching N (not B0) proves event-sourced reads. + let q_b0 = amounts_out_at(&provider, b0, amount_in).await?; + assert_ne!( + sim.amount_out, q_b0, + "sim must NOT equal the B0 cold-start quote (would indicate a backend refetch, not event sync)" + ); + + // NO-CHEAT 2: if the chain moved past N, sim must NOT equal the live-head + // quote (would indicate a live refetch instead of frozen event-state). + let m = provider.get_block_number().await?; + if m > last_block { + let q_m = amounts_out_at(&provider, m, amount_in).await?; + if q_m != q_n { + assert_ne!( + sim.amount_out, q_m, + "sim must reflect event-state (N), not the live head (M)" + ); + eprintln!("[ws-e2e] no-cheat: sim==Q_N({q_n}) != Q_M({q_m}) at live block {m}"); + } else { + eprintln!("[ws-e2e] live head {m} quote unchanged vs N; no-cheat-2 inconclusive"); + } + } + + eprintln!("[ws-e2e] PASS: WebSocket-event-only sync kept state accurate for swap simulation."); + Ok(()) +} + +/// Fast (~45s) subscription health probe: does the derived `wss://` actually +/// PUSH logs? Subscribes to ALL Uniswap-V2-style `Sync` events (topic-only, no +/// address filter — hundreds per minute on mainnet) and asserts it receives +/// some. Isolates "subscription transport works" from "the target pool was +/// quiet during the window". +#[tokio::test(flavor = "multi_thread")] +#[ignore = "live WS health probe; run with --ignored --nocapture"] +async fn ws_subscription_health_probe() -> Result<()> { + let Ok(rpc) = std::env::var("E2E_RPC_URL") else { + eprintln!("E2E_RPC_URL unset; skipping"); + return Ok(()); + }; + let ws_url = rpc + .replacen("https://", "wss://", 1) + .replacen("http://", "ws://", 1); + let provider = Arc::new( + RootProvider::::connect(&ws_url) + .await + .context("connect wss://")?, + ); + + let sync_topic = keccak256("Sync(uint112,uint112)"); + let filter = Filter::new().event_signature(sync_topic); + let mut stream = provider + .subscribe_logs(&filter) + .await + .context("subscribe_logs")? + .into_stream(); + + let mut count = 0u64; + let deadline = tokio::time::Instant::now() + Duration::from_secs(45); + loop { + tokio::select! { + _ = tokio::time::sleep_until(deadline) => break, + maybe = stream.next() => match maybe { + Some(_) => count += 1, + None => break, + }, + } + } + eprintln!( + "[ws-probe] received {count} all-V2 Sync events in 45s over the derived wss subscription" + ); + assert!( + count > 0, + "WS subscription delivered ZERO logs in 45s despite heavy on-chain activity — transport not pushing" + ); + Ok(()) +}