From c46bb215672d17575def29d7fafb32342ea705fd Mon Sep 17 00:00:00 2001 From: Mihir Wadekar Date: Tue, 10 Feb 2026 18:12:36 -0800 Subject: [PATCH] feat: introduces the rpc collector service We rewrite the rpc collector service in rust and open source it. --- Cargo.lock | 643 +++++++++++++----- Cargo.toml | 120 +++- bin/rpc-collector/Cargo.toml | 28 + bin/rpc-collector/README.md | 3 + bin/rpc-collector/src/main.rs | 453 ++++++++++++ crates/basectl/Cargo.toml | 2 +- crates/basectl/src/app/resources.rs | 2 +- crates/basectl/src/app/runner.rs | 2 +- crates/basectl/src/rpc.rs | 2 +- .../mempool-rebroadcaster/tests/e2e_tests.rs | 2 +- crates/rpc-collector/Cargo.toml | 57 ++ crates/rpc-collector/src/bindings.rs | 211 ++++++ crates/rpc-collector/src/lib.rs | 85 +++ crates/rpc-collector/src/metrics.rs | 219 ++++++ .../src/recorders/batches_info.rs | 122 ++++ .../src/recorders/dispute_game_factory.rs | 63 ++ .../src/recorders/eth_balance.rs | 61 ++ .../src/recorders/event_counter.rs | 63 ++ .../src/recorders/gas_price_oracle.rs | 62 ++ .../src/recorders/l2_block_info.rs | 192 ++++++ crates/rpc-collector/src/recorders/mod.rs | 30 + .../src/recorders/output_oracle.rs | 45 ++ .../src/recorders/reorg_detector.rs | 163 +++++ .../src/services/collector/l1.rs | 260 +++++++ .../src/services/collector/l2.rs | 190 ++++++ .../src/services/collector/mod.rs | 349 ++++++++++ .../src/services/divergence_checker.rs | 181 +++++ .../src/services/flashblock_validator.rs | 525 ++++++++++++++ .../src/services/health_checker.rs | 315 +++++++++ .../src/services/health_server.rs | 118 ++++ .../src/services/mempool_listener.rs | 577 ++++++++++++++++ crates/rpc-collector/src/services/mod.rs | 39 ++ .../rpc-collector/src/services/snapshots.rs | 211 ++++++ crates/rpc-collector/src/utils.rs | 119 ++++ 34 files changed, 5314 insertions(+), 200 deletions(-) create mode 100644 bin/rpc-collector/Cargo.toml create mode 100644 bin/rpc-collector/README.md create mode 100644 bin/rpc-collector/src/main.rs create mode 100644 crates/rpc-collector/Cargo.toml create mode 100644 crates/rpc-collector/src/bindings.rs create mode 100644 crates/rpc-collector/src/lib.rs create mode 100644 crates/rpc-collector/src/metrics.rs create mode 100644 crates/rpc-collector/src/recorders/batches_info.rs create mode 100644 crates/rpc-collector/src/recorders/dispute_game_factory.rs create mode 100644 crates/rpc-collector/src/recorders/eth_balance.rs create mode 100644 crates/rpc-collector/src/recorders/event_counter.rs create mode 100644 crates/rpc-collector/src/recorders/gas_price_oracle.rs create mode 100644 crates/rpc-collector/src/recorders/l2_block_info.rs create mode 100644 crates/rpc-collector/src/recorders/mod.rs create mode 100644 crates/rpc-collector/src/recorders/output_oracle.rs create mode 100644 crates/rpc-collector/src/recorders/reorg_detector.rs create mode 100644 crates/rpc-collector/src/services/collector/l1.rs create mode 100644 crates/rpc-collector/src/services/collector/l2.rs create mode 100644 crates/rpc-collector/src/services/collector/mod.rs create mode 100644 crates/rpc-collector/src/services/divergence_checker.rs create mode 100644 crates/rpc-collector/src/services/flashblock_validator.rs create mode 100644 crates/rpc-collector/src/services/health_checker.rs create mode 100644 crates/rpc-collector/src/services/health_server.rs create mode 100644 crates/rpc-collector/src/services/mempool_listener.rs create mode 100644 crates/rpc-collector/src/services/mod.rs create mode 100644 crates/rpc-collector/src/services/snapshots.rs create mode 100644 crates/rpc-collector/src/utils.rs diff --git a/Cargo.lock b/Cargo.lock index ccf4f1d..bbd258e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -51,9 +51,9 @@ dependencies = [ [[package]] name = "alloy-consensus" -version = "1.6.1" +version = "1.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86debde32d8dbb0ab29e7cc75ae1a98688ac7a4c9da54b3a9b14593b9b3c46d3" +checksum = "4e4ff99651d46cef43767b5e8262ea228cd05287409ccb0c947cc25e70a952f9" dependencies = [ "alloy-eips", "alloy-primitives", @@ -78,9 +78,9 @@ dependencies = [ [[package]] name = "alloy-consensus-any" -version = "1.6.1" +version = "1.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d6cb2e7efd385b333f5a77b71baaa2605f7e22f1d583f2879543b54cbce777c" +checksum = "1a0701b0eda8051a2398591113e7862f807ccdd3315d0b441f06c2a0865a379b" dependencies = [ "alloy-consensus", "alloy-eips", @@ -92,9 +92,9 @@ dependencies = [ [[package]] name = "alloy-contract" -version = "1.6.1" +version = "1.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "668859fcdb42eee289de22a9d01758c910955bb6ecda675b97276f99ce2e16b0" +checksum = "f3c83c7a3c4e1151e8cac383d0a67ddf358f37e5ea51c95a1283d897c9de0a5a" dependencies = [ "alloy-consensus", "alloy-dyn-abi", @@ -114,9 +114,9 @@ dependencies = [ [[package]] name = "alloy-dyn-abi" -version = "1.5.4" +version = "1.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14ff5ee5f27aa305bda825c735f686ad71bb65508158f059f513895abe69b8c3" +checksum = "e6ab1b2f1b48a7e6b3597cb2afae04f93879fb69d71e39736b5663d7366b23f2" dependencies = [ "alloy-json-abi", "alloy-primitives", @@ -180,9 +180,9 @@ dependencies = [ [[package]] name = "alloy-eips" -version = "1.6.1" +version = "1.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be47bf1b91674a5f394b9ed3c691d764fb58ba43937f1371550ff4bc8e59c295" +checksum = "def1626eea28d48c6cc0a6f16f34d4af0001906e4f889df6c660b39c86fd044d" dependencies = [ "alloy-eip2124", "alloy-eip2930", @@ -204,9 +204,9 @@ dependencies = [ [[package]] name = "alloy-json-abi" -version = "1.5.4" +version = "1.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8708475665cc00e081c085886e68eada2f64cfa08fc668213a9231655093d4de" +checksum = "1e414aa37b335ad2acb78a95814c59d137d53139b412f87aed1e10e2d862cd49" dependencies = [ "alloy-primitives", "alloy-sol-type-parser", @@ -216,9 +216,9 @@ dependencies = [ [[package]] name = "alloy-json-rpc" -version = "1.6.1" +version = "1.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a24c81a56d684f525cd1c012619815ad3a1dd13b0238f069356795d84647d3c" +checksum = "e57586581f2008933241d16c3e3f633168b3a5d2738c5c42ea5246ec5e0ef17a" dependencies = [ "alloy-primitives", "alloy-sol-types", @@ -231,9 +231,9 @@ dependencies = [ [[package]] name = "alloy-network" -version = "1.6.1" +version = "1.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "786c5b3ad530eaf43cda450f973fe7fb1c127b4c8990adf66709dafca25e3f6f" +checksum = "3b36c2a0ed74e48851f78415ca5b465211bd678891ba11e88fee09eac534bab1" dependencies = [ "alloy-consensus", "alloy-consensus-any", @@ -257,9 +257,9 @@ dependencies = [ [[package]] name = "alloy-network-primitives" -version = "1.6.1" +version = "1.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1ed40adf21ae4be786ef5eb62db9c692f6a30f86d34452ca3f849d6390ce319" +checksum = "636c8051da58802e757b76c3b65af610b95799f72423dc955737dec73de234fd" dependencies = [ "alloy-consensus", "alloy-eips", @@ -270,9 +270,9 @@ dependencies = [ [[package]] name = "alloy-primitives" -version = "1.5.4" +version = "1.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b88cf92ed20685979ed1d8472422f0c6c2d010cec77caf63aaa7669cc1a7bc2" +checksum = "66b1483f8c2562bf35f0270b697d5b5fe8170464e935bd855a4c5eaf6f89b354" dependencies = [ "alloy-rlp", "bytes", @@ -297,9 +297,9 @@ dependencies = [ [[package]] name = "alloy-provider" -version = "1.6.1" +version = "1.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3ca4c15818be7ac86208aff3a91b951d14c24e1426e66624e75f2215ba5e2cc" +checksum = "b3dd56e2eafe8b1803e325867ac2c8a4c73c9fb5f341ffd8347f9344458c5922" dependencies = [ "alloy-chains", "alloy-consensus", @@ -339,9 +339,9 @@ dependencies = [ [[package]] name = "alloy-pubsub" -version = "1.6.1" +version = "1.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9eb9c9371738ac47f589e40aae6e418cb5f7436ad25b87575a7f94a60ccf43b" +checksum = "6eebf54983d4fccea08053c218ee5c288adf2e660095a243d0532a8070b43955" dependencies = [ "alloy-json-rpc", "alloy-primitives", @@ -383,9 +383,9 @@ dependencies = [ [[package]] name = "alloy-rpc-client" -version = "1.6.1" +version = "1.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe0addad5b8197e851062b49dc47157444bced173b601d91e3f9b561a060a50" +checksum = "91577235d341a1bdbee30a463655d08504408a4d51e9f72edbfc5a622829f402" dependencies = [ "alloy-json-rpc", "alloy-primitives", @@ -408,9 +408,9 @@ dependencies = [ [[package]] name = "alloy-rpc-types" -version = "1.6.1" +version = "1.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74d17d4645a717f0527e491f44f6f7a75c221b9c00ccf79ddba2d26c8e0df4c3" +checksum = "79cff039bf01a17d76c0aace3a3a773d5f895eb4c68baaae729ec9da9e86c99c" dependencies = [ "alloy-primitives", "alloy-rpc-types-txpool", @@ -420,9 +420,9 @@ dependencies = [ [[package]] name = "alloy-rpc-types-any" -version = "1.6.1" +version = "1.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0e98aabb013a71a4b67b52825f7b503e5bb6057fb3b7b2290d514b0b0574b57" +checksum = "73234a141ecce14e2989748c04fcac23deee67a445e2c4c167cfb42d4dacd1b6" dependencies = [ "alloy-consensus-any", "alloy-rpc-types-eth", @@ -431,9 +431,9 @@ dependencies = [ [[package]] name = "alloy-rpc-types-engine" -version = "1.6.1" +version = "1.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddc871ae69688e358cf242a6a7ee6b6e0476a03fd0256434c68daedaec086ec4" +checksum = "10620d600cc46538f613c561ac9a923843c6c74c61f054828dcdb8dd18c72ec4" dependencies = [ "alloy-consensus", "alloy-eips", @@ -449,9 +449,9 @@ dependencies = [ [[package]] name = "alloy-rpc-types-eth" -version = "1.6.1" +version = "1.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5899af8417dcf89f40f88fa3bdb2f3f172605d8e167234311ee34811bbfdb0bf" +checksum = "010e101dbebe0c678248907a2545b574a87d078d82c2f6f5d0e8e7c9a6149a10" dependencies = [ "alloy-consensus", "alloy-consensus-any", @@ -470,9 +470,9 @@ dependencies = [ [[package]] name = "alloy-rpc-types-txpool" -version = "1.6.1" +version = "1.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a8074654c0292783d504bfa1f2691a69f420154ee9a7883f9212eaf611e60cd" +checksum = "14ab75189fbc29c5dd6f0bc1529bccef7b00773b458763f4d9d81a77ae4a1a2d" dependencies = [ "alloy-primitives", "alloy-rpc-types-eth", @@ -482,9 +482,9 @@ dependencies = [ [[package]] name = "alloy-serde" -version = "1.6.1" +version = "1.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "feb73325ee881e42972a5a7bc85250f6af89f92c6ad1222285f74384a203abeb" +checksum = "9e6d631f8b975229361d8af7b2c749af31c73b3cf1352f90e144ddb06227105e" dependencies = [ "alloy-primitives", "serde", @@ -493,9 +493,9 @@ dependencies = [ [[package]] name = "alloy-signer" -version = "1.6.1" +version = "1.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bea4c8f30eddb11d7ab56e83e49c814655daa78ca708df26c300c10d0189cbc" +checksum = "97f40010b5e8f79b70bf163b38cd15f529b18ca88c4427c0e43441ee54e4ed82" dependencies = [ "alloy-primitives", "async-trait", @@ -508,9 +508,9 @@ dependencies = [ [[package]] name = "alloy-sol-macro" -version = "1.5.4" +version = "1.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5fa1ca7e617c634d2bd9fa71f9ec8e47c07106e248b9fcbd3eaddc13cabd625" +checksum = "2c4b64c8146291f750c3f391dff2dd40cf896f7e2b253417a31e342aa7265baa" dependencies = [ "alloy-sol-macro-expander", "alloy-sol-macro-input", @@ -522,9 +522,9 @@ dependencies = [ [[package]] name = "alloy-sol-macro-expander" -version = "1.5.4" +version = "1.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27c00c0c3a75150a9dc7c8c679ca21853a137888b4e1c5569f92d7e2b15b5102" +checksum = "d9df903674682f9bae8d43fdea535ab48df2d6a8cb5104ca29c58ada22ef67b3" dependencies = [ "alloy-sol-macro-input", "const-hex", @@ -540,9 +540,9 @@ dependencies = [ [[package]] name = "alloy-sol-macro-input" -version = "1.5.4" +version = "1.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "297db260eb4d67c105f68d6ba11b8874eec681caec5505eab8fbebee97f790bc" +checksum = "737b8a959f527a86e07c44656db237024a32ae9b97d449f788262a547e8aa136" dependencies = [ "const-hex", "dunce", @@ -556,9 +556,9 @@ dependencies = [ [[package]] name = "alloy-sol-type-parser" -version = "1.5.4" +version = "1.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94b91b13181d3bcd23680fd29d7bc861d1f33fbe90fdd0af67162434aeba902d" +checksum = "b28e6e86c6d2db52654b65a5a76b4f57eae5a32a7f0aa2222d1dbdb74e2cb8e0" dependencies = [ "serde", "winnow", @@ -566,9 +566,9 @@ dependencies = [ [[package]] name = "alloy-sol-types" -version = "1.5.4" +version = "1.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc442cc2a75207b708d481314098a0f8b6f7b58e3148dd8d8cc7407b0d6f9385" +checksum = "fdf7effe4ab0a4f52c865959f790036e61a7983f68b13b75d7fbcedf20b753ce" dependencies = [ "alloy-json-abi", "alloy-primitives", @@ -578,9 +578,9 @@ dependencies = [ [[package]] name = "alloy-transport" -version = "1.6.1" +version = "1.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b321f506bd67a434aae8e8a7dfe5373bf66137c149a5f09c9e7dfb0ca43d7c91" +checksum = "a03bb3f02b9a7ab23dacd1822fa7f69aa5c8eefcdcf57fad085e0b8d76fb4334" dependencies = [ "alloy-json-rpc", "auto_impl", @@ -601,12 +601,13 @@ dependencies = [ [[package]] name = "alloy-transport-http" -version = "1.6.1" +version = "1.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30bf12879a20e1261cd39c3b101856f52d18886907a826e102538897f0d2b66e" +checksum = "5ce599598ef8ebe067f3627509358d9faaa1ef94f77f834a7783cd44209ef55c" dependencies = [ "alloy-json-rpc", "alloy-transport", + "itertools 0.14.0", "opentelemetry", "opentelemetry-http", "reqwest", @@ -619,9 +620,9 @@ dependencies = [ [[package]] name = "alloy-transport-ws" -version = "1.6.1" +version = "1.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "527a0d9c8bbc5c3215b03ad465d4ae8775384ff5faec7c41f033f087c851a9f9" +checksum = "5ed38ea573c6658e0c2745af9d1f1773b1ed83aa59fbd9c286358ad469c3233a" dependencies = [ "alloy-pubsub", "alloy-transport", @@ -653,9 +654,9 @@ dependencies = [ [[package]] name = "alloy-tx-macros" -version = "1.6.1" +version = "1.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a91d6b4c2f6574fdbcb1611e460455c326667cf5b805c6bd1640dad8e8ee4d2" +checksum = "397406cf04b11ca2a48e6f81804c70af3f40a36abf648e11dc7416043eb0834d" dependencies = [ "darling 0.21.3", "proc-macro2", @@ -672,56 +673,12 @@ dependencies = [ "libc", ] -[[package]] -name = "anstream" -version = "0.6.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - [[package]] name = "anstyle" version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" -[[package]] -name = "anstyle-parse" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "anstyle-wincon" -version = "3.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" -dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys 0.61.2", -] - [[package]] name = "anyhow" version = "1.0.101" @@ -1014,9 +971,57 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] -name = "base-flashtypes" +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "base-primitives" version = "0.0.0" -source = "git+https://github.com/base/base.git#720f6e1a73faabc938402b0cb617d3960db742d4" +source = "git+https://github.com/base/base.git#d8adce06b1b8d86f2ccdee5688004569f42789a2" dependencies = [ "alloy-primitives", "alloy-rpc-types-engine", @@ -1069,7 +1074,7 @@ dependencies = [ "alloy-sol-types", "anyhow", "arboard", - "base-flashtypes", + "base-primitives", "chrono", "crossterm", "dirs", @@ -1262,9 +1267,9 @@ dependencies = [ [[package]] name = "cadence" -version = "1.6.0" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3075f133bee430b7644c54fb629b9b4420346ffa275a45c81a6babe8b09b4f51" +checksum = "d7aff0c323415907f37007d645d7499c378df47efb3e33ffc1f397fa4e549b2e" dependencies = [ "crossbeam-channel", ] @@ -1322,9 +1327,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.57" +version = "4.5.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6899ea499e3fb9305a65d5ebf6e3d2248c5fab291f300ad0a704fbe142eae31a" +checksum = "63be97961acde393029492ce0be7a1af7e323e6bae9511ebfac33751be5e6806" dependencies = [ "clap_builder", "clap_derive", @@ -1332,14 +1337,12 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.57" +version = "4.5.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b12c8b680195a62a8364d16b8447b01b6c2c8f9aaf68bee653be34d4245e238" +checksum = "7f13174bda5dfd69d7e947827e5af4b0f2f94a4a3ee92912fba07a66150f21e2" dependencies = [ - "anstream", "anstyle", "clap_lex", - "strsim", ] [[package]] @@ -1356,9 +1359,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" [[package]] name = "clipboard-win" @@ -1369,12 +1372,6 @@ dependencies = [ "error-code", ] -[[package]] -name = "colorchoice" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" - [[package]] name = "compact_str" version = "0.8.1" @@ -1655,9 +1652,9 @@ dependencies = [ [[package]] name = "deranged" -version = "0.5.5" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +checksum = "cc3dc5ad92c2e2d1c193bbbbdf2ea477cb81331de4f3103f267ca18368b988c4" dependencies = [ "powerfmt", "serde_core", @@ -2155,9 +2152,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", ] [[package]] @@ -2299,6 +2311,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.8.1" @@ -2312,6 +2330,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "pin-utils", @@ -2320,6 +2339,23 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.6", +] + [[package]] name = "hyper-tls" version = "0.6.0" @@ -2464,6 +2500,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -2586,12 +2628,6 @@ dependencies = [ "serde", ] -[[package]] -name = "is_terminal_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" - [[package]] name = "itertools" version = "0.10.5" @@ -2689,11 +2725,17 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" -version = "0.2.180" +version = "0.2.181" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +checksum = "459427e2af2b9c839b132acb702a1c654d95e10f8c326bfc2ad11310e458b1c5" [[package]] name = "libm" @@ -2762,6 +2804,12 @@ dependencies = [ "hashbrown 0.16.1", ] +[[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.1.4" @@ -2782,6 +2830,12 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "memchr" version = "2.8.0" @@ -2817,6 +2871,12 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -3034,12 +3094,6 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" -[[package]] -name = "once_cell_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" - [[package]] name = "op-alloy-consensus" version = "0.22.4" @@ -3347,6 +3401,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.114", +] + [[package]] name = "primitive-types" version = "0.12.2" @@ -3438,6 +3502,61 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" +[[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", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "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.44" @@ -3532,9 +3651,9 @@ dependencies = [ [[package]] name = "rapidhash" -version = "4.2.2" +version = "4.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71ec30b38a417407efe7676bad0ca6b78f995f810185ece9af3bd5dc561185a9" +checksum = "84816e4c99c467e92cf984ee6328caa976dfecd33a673544489d79ca2caaefe5" dependencies = [ "rustversion", ] @@ -3630,6 +3749,7 @@ dependencies = [ "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-tls", "hyper-util", "js-sys", @@ -3637,6 +3757,8 @@ dependencies = [ "native-tls", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", "rustls-pki-types", "serde", "serde_json", @@ -3644,6 +3766,7 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-native-tls", + "tokio-rustls", "tower", "tower-http", "tower-service", @@ -3651,6 +3774,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "webpki-roots 1.0.6", ] [[package]] @@ -3687,6 +3811,61 @@ dependencies = [ "rustc-hex", ] +[[package]] +name = "rpc-collector" +version = "0.0.0" +dependencies = [ + "alloy-consensus", + "alloy-contract", + "alloy-eips", + "alloy-network", + "alloy-network-primitives", + "alloy-primitives", + "alloy-provider", + "alloy-rlp", + "alloy-rpc-client", + "alloy-rpc-types-eth", + "alloy-sol-types", + "alloy-transport-http", + "anyhow", + "async-trait", + "axum", + "base-primitives", + "cadence", + "futures-util", + "op-alloy-consensus", + "op-alloy-network", + "op-alloy-rpc-types", + "reqwest", + "serde", + "serde_json", + "tokio", + "tokio-tungstenite", + "tokio-util", + "tracing", + "url", +] + +[[package]] +name = "rpc-collector-bin" +version = "0.0.0" +dependencies = [ + "alloy-primitives", + "alloy-provider", + "alloy-sol-types", + "anyhow", + "cadence", + "clap", + "dotenvy", + "op-alloy-network", + "rpc-collector", + "tokio", + "tokio-util", + "tracing", + "tracing-subscriber", + "url", +] + [[package]] name = "ruint" version = "1.17.2" @@ -3784,6 +3963,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" dependencies = [ "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -3796,6 +3976,7 @@ version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ + "web-time", "zeroize", ] @@ -3830,9 +4011,9 @@ dependencies = [ [[package]] name = "ryu" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "schannel" @@ -4005,6 +4186,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -4341,9 +4533,9 @@ dependencies = [ [[package]] name = "syn-solidity" -version = "1.5.4" +version = "1.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2379beea9476b89d0237078be761cf8e012d92d5ae4ae0c9a329f974838870fc" +checksum = "f8658017776544996edc21c8c7cc8bb4f13db60955382f4bac25dc6303b38438" dependencies = [ "paste", "proc-macro2", @@ -4379,12 +4571,12 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" [[package]] name = "tempfile" -version = "3.24.0" +version = "3.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.1", "once_cell", "rustix 1.1.3", "windows-sys 0.61.2", @@ -4483,6 +4675,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.49.0" @@ -4570,6 +4777,7 @@ dependencies = [ "bytes", "futures-core", "futures-sink", + "futures-util", "pin-project-lite", "tokio", ] @@ -4597,9 +4805,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.0.6+spec-1.1.0" +version = "1.0.7+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" +checksum = "247eaa3197818b831697600aadf81514e577e0cba5eab10f7e064e78ae154df1" dependencies = [ "winnow", ] @@ -4795,9 +5003,9 @@ checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" [[package]] name = "unicode-ident" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" [[package]] name = "unicode-segmentation" @@ -4871,12 +5079,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" -[[package]] -name = "utf8parse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" - [[package]] name = "valuable" version = "0.1.1" @@ -4928,6 +5130,15 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.108" @@ -4987,6 +5198,40 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.13.0", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap 2.13.0", + "semver 1.0.27", +] + [[package]] name = "wasmtimer" version = "0.4.3" @@ -5305,6 +5550,88 @@ name = "wit-bindgen" version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap 2.13.0", + "prettyplease", + "syn 2.0.114", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.114", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap 2.13.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.13.0", + "log", + "semver 1.0.27", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] [[package]] name = "writeable" @@ -5476,9 +5803,9 @@ dependencies = [ [[package]] name = "zmij" -version = "1.0.19" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445" +checksum = "4de98dfa5d5b7fef4ee834d0073d560c9ca7b6c46a71d058c48db7960f8cfaf7" [[package]] name = "zune-core" diff --git a/Cargo.toml b/Cargo.toml index c102d3e..da6af45 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,54 +44,100 @@ needless-pass-by-ref-mut = "warn" string-lit-as-bytes = "warn" [workspace.dependencies] -cadence = "1.4" -clap = { version = "4.0", features = ["derive", "env"] } -tokio-tungstenite = { version = "0.26", features = ["native-tls"] } -futures-util = "0.3" -url = "2.5" -ratatui = "0.29" -crossterm = "0.28" -chrono = "0.4" -anyhow = "1.0" -serde_yaml = "0.9" -dirs = "6.0" -arboard = "3.4" -dotenvy = "0.15.7" -async-trait = "0.1" -mempool-rebroadcaster = { path = "./crates/mempool-rebroadcaster" } -sidecrush = { path = "./crates/sidecrush" } -metrics = "0.24.1" -metrics-derive = "0.1" -tracing = "0.1" -tracing-subscriber = { version = "0.3", default-features = false, features = ["env-filter", "fmt", "ansi", "json"] } -tokio = { version = "1.0", features = ["full"] } -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" +# Internal crates +basectl-cli = { path = "crates/basectl" } +mempool-rebroadcaster = { path = "crates/mempool-rebroadcaster" } +rpc-collector = { path = "crates/rpc-collector" } +sidecrush = { path = "crates/sidecrush" } + +# base-reth +base-bundles = { git = "https://github.com/base/base", branch = "main" } +base-primitives = { git = "https://github.com/base/base.git", features = ["flashblocks"] } +base-reth-rpc-types = { git = "https://github.com/base/base", branch = "main" } + +# revm +op-revm = { version = "15.0.0", default-features = false } +revm-context-interface = { version = "15.0.0", default-features = false } # alloy +alloy-consensus = { version = "1.5.2" } +alloy-contract = { version = "1.5.2" } +alloy-eips = { version = "1.5.2", default-features = false } +alloy-genesis = { version = "1.5.2", default-features = false } +alloy-hardforks = { version = "0.5" } +alloy-network = { version = "1.6.3" } +alloy-network-primitives = { version = "1.6.3" } alloy-primitives = { version = "1.5.2", default-features = false, features = [ "map-foldhash", ] } -alloy-genesis = { version = "1.5.2", default-features = false } -alloy-eips = { version = "1.5.2", default-features = false } +alloy-provider = { version = "1.5.2", features = ["ws", "pubsub"] } +alloy-rlp = { version = "0.3", default-features = false } +alloy-rpc-client = { version = "1.5.2" } alloy-rpc-types = { version = "1.5.2", default-features = false } alloy-rpc-types-engine = { version = "1.5.2", default-features = false } alloy-rpc-types-eth = { version = "1.5.2" } -alloy-consensus = { version = "1.5.2" } -alloy-trie = { version = "0.9.1", default-features = false } -alloy-provider = { version = "1.5.2", features = ["ws", "pubsub"] } -alloy-hardforks = { version = "0.5" } -alloy-rpc-client = { version = "1.5.2" } -alloy-transport-http = { version = "1.5.2" } alloy-sol-types = { version = "1.5.2" } -alloy-contract = { version = "1.5.2" } +alloy-transport-http = { version = "1.5.2" } +alloy-trie = { version = "0.9.1", default-features = false } # op-alloy -op-alloy-network = { version = "0.22.0", default-features = false } +op-alloy-consensus = { version = "0.22.0", default-features = false } +op-alloy-network = { version = "0.22.0", default-features = false } op-alloy-rpc-types = { version = "0.22.0", default-features = false } op-alloy-rpc-types-engine = { version = "0.22.0", default-features = false } -op-alloy-consensus = { version = "0.22.0", default-features = false } -# base -base-flashtypes = { git = "https://github.com/base/base.git" } -basectl-cli = { path = "crates/basectl" } +# tokio & async +async-trait = "0.1.89" +futures = { version = "0.3.31", default-features = false } +futures-util = "0.3" +tokio = { version = "1.47.1", features = ["full"] } +tokio-tungstenite = { version = "0.26", features = ["native-tls"] } +tokio-util = { version = "0.7", default-features = false } + +# networking & rpc +axum = { version = "0.8.3", default-features = false } +jsonrpsee = { version = "0.26.0", default-features = false } +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } +url = { version = "2.5.7", default-features = false } + +# kafka & s3 +aws-config = { version = "1.1.7", default-features = false } +aws-credential-types = { version = "1.1.7", default-features = false } +aws-sdk-s3 = { version = "1.106.0", default-features = false } +bytes = { version = "1.8.0", default-features = false } +rdkafka = { version = "0.37.0", default-features = false } + +# serialization +serde = { version = "1.0.219", default-features = false, features = ["derive"] } +serde_json = { version = "1.0.143", default-features = false } +serde_yaml = "0.9" + +# observability +cadence = "1.4" +metrics = { version = "0.24.1", default-features = false } +metrics-derive = { version = "0.1", default-features = false } +metrics-exporter-prometheus = { version = "0.17.0", default-features = false } +tracing = { version = "0.1.41", default-features = false } +tracing-subscriber = { version = "0.3.20", default-features = false, features = ["env-filter", "fmt", "ansi", "json"] } + +# cli & tui +clap = { version = "4.5.47", default-features = false, features = ["std", "derive", "env"] } +crossterm = "0.28" +ratatui = "0.29" + +# testing +testcontainers = { version = "0.23.1", default-features = false } +testcontainers-modules = { version = "0.11.2", default-features = false } +wiremock = { version = "0.6.2", default-features = false } + +# misc +anyhow = { version = "1.0.99", default-features = false } +arboard = "3.4" +backon = { version = "1.5.2", default-features = false } +brotli = "8" +chrono = "0.4" +dirs = "6.0" +dotenvy = { version = "0.15.7", default-features = false } +moka = { version = "0.12.12", default-features = false } +regex = "1" +uuid = { version = "1.18.1", default-features = false } diff --git a/bin/rpc-collector/Cargo.toml b/bin/rpc-collector/Cargo.toml new file mode 100644 index 0000000..848a9d6 --- /dev/null +++ b/bin/rpc-collector/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "rpc-collector-bin" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[[bin]] +name = "rpc-collector" +path = "src/main.rs" + +[dependencies] +rpc-collector.workspace = true +clap.workspace = true +tokio.workspace = true +anyhow.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +dotenvy.workspace = true +cadence.workspace = true +alloy-provider = { workspace = true, features = ["reqwest"] } +op-alloy-network.workspace = true +url.workspace = true +alloy-primitives.workspace = true +alloy-sol-types.workspace = true +tokio-util = { workspace = true } diff --git a/bin/rpc-collector/README.md b/bin/rpc-collector/README.md new file mode 100644 index 0000000..8c079f4 --- /dev/null +++ b/bin/rpc-collector/README.md @@ -0,0 +1,3 @@ +# `rpc-collector` + +The RPC data collector service. diff --git a/bin/rpc-collector/src/main.rs b/bin/rpc-collector/src/main.rs new file mode 100644 index 0000000..dd51248 --- /dev/null +++ b/bin/rpc-collector/src/main.rs @@ -0,0 +1,453 @@ +//! `rpc-collector` — binary entry point. +//! +//! Parses CLI / env-var configuration, creates providers, wires up every +//! subsystem from the `rpc-collector` library crate, and runs them all +//! concurrently until a shutdown signal is received. + +use std::{net::SocketAddr, sync::Arc, time::Duration}; + +use alloy_primitives::Address; +use alloy_provider::{Provider, ProviderBuilder}; +use anyhow::Context; +use clap::Parser; +use rpc_collector::{ + metrics::Metrics, + services::{ + DivergenceCheckerService, FlashblockValidator, HealthChecker, HealthServer, L1Collector, + L1CollectorConfig, L2Collector, L2CollectorConfig, MempoolListenerService, Node, Service, + SnapshotsService, build_node_list, build_node_list_sanitized, + }, +}; +use tokio::task::JoinSet; +use tokio_util::sync::CancellationToken; +use tracing::{error, info, warn}; +use url::Url; + +/// Configuration for the RPC Collector service. +#[derive(Parser, Debug, Clone)] +#[command( + name = "rpc-collector", + version, + about = "Service for collecting OPStack metrics from RPC endpoints" +)] +struct Args { + // ── RPC URLs ────────────────────────────────────────────── + /// RPC URL for L1 (Ethereum Mainnet). + #[arg(long, env = "RPC_COLLECTOR_RPC_L1_URL")] + pub l1_rpc_url: Url, + + /// RPC URL for L2 (OP Stack chain). + #[arg(long, env = "RPC_COLLECTOR_RPC_L2_URL")] + pub l2_rpc_url: Url, + + // ── Poll intervals ─────────────────────────────────────── + /// Interval in ms to poll L1 for new blocks. + #[arg(long, env = "RPC_COLLECTOR_POLL_L1_INTERVAL_MS")] + pub poll_l1_interval_ms: u64, + + /// Interval in ms to poll L2 for new blocks. + #[arg(long, env = "RPC_COLLECTOR_POLL_L2_INTERVAL_MS")] + pub poll_l2_interval_ms: u64, + + /// Interval in ms to poll node health checks. + #[arg(long, env = "RPC_COLLECTOR_POLL_HEALTH_CHECK_INTERVAL_MS")] + pub poll_health_check_interval_ms: u64, + + // ── L1 contract addresses ──────────────────────────────── + /// Address of the Optimism Portal contract on L1. + #[arg(long, env = "RPC_COLLECTOR_OPTIMISM_PORTAL_ADDRESS")] + pub optimism_portal_address: Address, + + /// Address of the L2 Output Oracle contract on L1 (legacy). + #[arg(long, env = "RPC_COLLECTOR_OUTPUT_ORACLE_ADDRESS")] + pub output_oracle_address: Option
, + + /// Address of the Dispute Game Factory contract on L1. + #[arg(long, env = "RPC_COLLECTOR_DISPUTE_GAME_FACTORY_ADDRESS")] + pub dispute_game_factory_address: Option
, + + /// Address of the L1 Standard Bridge contract. + #[arg(long, env = "RPC_COLLECTOR_L1_STANDARD_BRIDGE_ADDRESS")] + pub l1_standard_bridge_address: Address, + + /// Address of the L1 System Config contract. + #[arg(long, env = "RPC_COLLECTOR_L1_SYSTEM_CONFIG_ADDRESS")] + pub l1_system_config_address: Address, + + /// Address of the proposer on L1. + #[arg(long, env = "RPC_COLLECTOR_ETHEREUM_PROPOSER_ADDRESS")] + pub ethereum_proposer_address: Address, + + /// Address of the batcher on L1. + #[arg(long, env = "RPC_COLLECTOR_ETHEREUM_BATCHER_ADDRESS")] + pub ethereum_batcher_address: Address, + + /// Address of the batch inbox on L1. + #[arg(long, env = "RPC_COLLECTOR_ETHEREUM_BATCH_INBOX_ADDRESS")] + pub batch_inbox_address: Address, + + /// Address of the balance tracker contract on L1. + #[arg(long, env = "RPC_COLLECTOR_BALANCE_TRACKER_ADDRESS")] + pub balance_tracker_address: Option
, + + // ── L2 contract addresses ──────────────────────────────── + /// Address of the fee disburser contract on L2. + #[arg(long, env = "RPC_COLLECTOR_FEE_DISBURSER_ADDRESS")] + pub fee_disburser_address: Option
, + + // ── Node lists ─────────────────────────────────────────── + /// L1 node URLs for health checks. + #[arg(long, env = "RPC_COLLECTOR_L1_NODES", value_delimiter = ',')] + pub l1_nodes: Vec, + + /// L2 node URLs for health checks. + #[arg(long, env = "RPC_COLLECTOR_L2_NODES", value_delimiter = ',')] + pub l2_nodes: Vec, + + /// Sequencer node URL. + #[arg(long, env = "RPC_COLLECTOR_L2_SEQUENCER")] + pub l2_sequencer: Option, + + /// External L2 node URLs (appended to `l2_nodes`, URLs sanitised). + #[arg(long, env = "RPC_COLLECTOR_EXTERNAL_NODES_URL", value_delimiter = ',')] + pub external_nodes_url: Vec, + + // ── Grace periods ──────────────────────────────────────── + /// Grace period in ms for L1 nodes to be considered healthy. + #[arg(long, env = "RPC_COLLECTOR_L1_NODES_GRACE_PERIOD_MS")] + pub l1_nodes_grace_period_ms: u64, + + /// Grace period in ms for L2 nodes to be considered healthy. + #[arg(long, env = "RPC_COLLECTOR_L2_NODES_GRACE_PERIOD_MS")] + pub l2_nodes_grace_period_ms: u64, + + // ── Contract balance calls ─────────────────────────────── + /// L1 contract balance calls: `metric|address|calldata` triples. + #[arg(long, env = "RPC_COLLECTOR_L1_CONTRACT_BALANCE_CALLS", value_delimiter = ',')] + pub l1_contract_balance_calls: Vec, + + /// L2 contract balance calls: `metric|address|calldata` triples. + #[arg(long, env = "RPC_COLLECTOR_L2_CONTRACT_BALANCE_CALLS", value_delimiter = ',')] + pub l2_contract_balance_calls: Vec, + + // ── Flashblocks ────────────────────────────────────────── + /// WebSocket URL for the Flashblock proxy. + #[arg(long, env = "RPC_COLLECTOR_FLASHBLOCK_WEBSOCKET_PROXY_URL")] + pub flashblock_websocket_url: Option, + + /// Flashblock rollup-boost WebSocket URLs. + #[arg(long, env = "RPC_COLLECTOR_FLASHBLOCK_WEBSOCKET_RB_URLS", value_delimiter = ',')] + pub flashblock_rb_urls: Vec, + + /// Flashblock builder WebSocket URLs. + #[arg(long, env = "RPC_COLLECTOR_FLASHBLOCK_WEBSOCKET_BUILDER_URLS", value_delimiter = ',')] + pub flashblock_builder_urls: Vec, + + // ── Mempool ────────────────────────────────────────────── + /// RPC URL for Geth mempool monitoring. + #[arg(long, env = "RPC_COLLECTOR_GETH_MEMPOOL")] + pub geth_mempool_rpc_url: Option, + + /// RPC URL for Reth mempool monitoring. + #[arg(long, env = "RPC_COLLECTOR_RETH_MEMPOOL")] + pub reth_mempool_rpc_url: Option, + + /// Interval in ms to poll mempool metrics. + #[arg(long, env = "RPC_COLLECTOR_POLL_MEMPOOL_INTERVAL_MS", default_value = "5000")] + pub poll_mempool_interval_ms: u64, + + // ── Divergence checker ─────────────────────────────────── + /// Geth node URL for divergence checking. + #[arg(long, env = "RPC_COLLECTOR_DIVERGENCE_GETH_NODE")] + pub divergence_geth_node: Option, + + /// Reth node URL for divergence checking. + #[arg(long, env = "RPC_COLLECTOR_DIVERGENCE_RETH_NODE")] + pub divergence_reth_node: Option, + + /// Grace period for Geth node sync in ms. + #[arg(long, env = "RPC_COLLECTOR_DIVERGENCE_GETH_GRACE_PERIOD_MS", default_value = "30000")] + pub divergence_geth_grace_period_ms: u64, + + /// Interval in ms to poll divergence checker. + #[arg(long, env = "RPC_COLLECTOR_DIVERGENCE_POLL_INTERVAL_MS", default_value = "5000")] + pub divergence_poll_interval_ms: u64, + + // ── Snapshots ──────────────────────────────────────────── + /// S3 bucket URLs for snapshot monitoring. + #[arg(long, env = "RPC_COLLECTOR_SNAPSHOT_BUCKETS", value_delimiter = ',')] + pub snapshot_buckets: Vec, + + // ── Observability / infra ──────────────────────────────── + /// Address for the health check HTTP server. + #[arg(long, env = "RPC_COLLECTOR_HEALTH_ADDR", default_value = "0.0.0.0:3001")] + pub health_addr: SocketAddr, + + /// `StatsD` host for metric emission. + #[arg(long, env = "RPC_COLLECTOR_STATSD_HOST", default_value = "127.0.0.1")] + pub statsd_host: String, + + /// `StatsD` port for metric emission. + #[arg(long, env = "RPC_COLLECTOR_STATSD_PORT", default_value = "8125")] + pub statsd_port: u16, + + /// `StatsD` metric prefix. + #[arg(long, env = "RPC_COLLECTOR_STATSD_PREFIX", default_value = "")] + pub statsd_prefix: String, + + /// Log level. + #[arg(long, env = "RPC_COLLECTOR_LOG_LEVEL", default_value = "info")] + pub log_level: String, + + /// Log format: `json` or `text`. + #[arg(long, env = "RPC_COLLECTOR_LOG_FORMAT", default_value = "text")] + pub log_format: String, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let _ = dotenvy::dotenv(); + let cfg = Args::parse(); + + init_tracing(&cfg.log_level, &cfg.log_format); + info!(version = env!("CARGO_PKG_VERSION"), "starting rpc-collector"); + + let m = Metrics::new(&cfg.statsd_host, cfg.statsd_port, &cfg.statsd_prefix) + .context("failed to create StatsD client")?; + + let cancel = CancellationToken::new(); + let l1 = Arc::new(ProviderBuilder::new().connect_http(cfg.l1_rpc_url.clone())); + let l2 = Arc::new(ProviderBuilder::new().connect_http(cfg.l2_rpc_url.clone())); + + let services = build_services(&cfg, &l1, &l2, &m)?; + + let mut set = JoinSet::new(); + info!(count = services.len(), "spawning services"); + for svc in services { + info!(name = svc.name(), "spawning service"); + svc.spawn(&mut set, cancel.clone()); + } + + info!("all services started — waiting for shutdown signal"); + await_shutdown(cancel, set).await; + + info!("rpc-collector shut down"); + Ok(()) +} + +/// Build all services from configuration and shared dependencies. +/// +/// Returns a `Vec>` that `main` can spawn uniformly. +pub(crate) fn build_services( + cfg: &Args, + l1: &Arc

, + l2: &Arc

, + m: &Metrics, +) -> anyhow::Result>> { + let mut services: Vec> = vec![]; + + // ── Health server ───────────────────────────────────── + let health_server = HealthServer { addr: cfg.health_addr }; + services.push(Box::new(health_server)); + + // ── L2 metrics collector ────────────────────────────── + let l2_collector = L2Collector::new( + L2CollectorConfig { + poll_interval: Duration::from_millis(cfg.poll_l2_interval_ms), + fee_disburser_address: cfg.fee_disburser_address, + l1_system_config_address: cfg.l1_system_config_address, + contract_balance_calls: cfg.l2_contract_balance_calls.clone(), + }, + Arc::clone(l2), + m.clone(), + )?; + services.push(Box::new(l2_collector)); + + // ── L1 metrics collector ────────────────────────────── + let l1_collector = L1Collector::new( + L1CollectorConfig { + poll_interval: Duration::from_millis(cfg.poll_l1_interval_ms), + optimism_portal_address: cfg.optimism_portal_address, + output_oracle_address: cfg.output_oracle_address, + dispute_game_factory_address: cfg.dispute_game_factory_address, + l1_standard_bridge_address: cfg.l1_standard_bridge_address, + ethereum_proposer_address: cfg.ethereum_proposer_address, + ethereum_batcher_address: cfg.ethereum_batcher_address, + batch_inbox_address: cfg.batch_inbox_address, + balance_tracker_address: cfg.balance_tracker_address, + contract_balance_calls: cfg.l1_contract_balance_calls.clone(), + }, + Arc::clone(l1), + m.clone(), + )?; + services.push(Box::new(l1_collector)); + + // ── L1 health checks ───────────────────────────── + let l1_health_checker = HealthChecker { + nodes: build_node_list(&cfg.l1_nodes, "internal"), + sequencer: None, + poll_interval: Duration::from_millis(cfg.poll_health_check_interval_ms), + grace_period: Duration::from_millis(cfg.l1_nodes_grace_period_ms), + metrics: m.clone(), + layer: "l1", + }; + services.push(Box::new(l1_health_checker)); + + // ── L2 health checks ───────────────────────────────── + let mut l2_nodes = build_node_list(&cfg.l2_nodes, "internal"); + l2_nodes.extend(build_node_list_sanitized(&cfg.external_nodes_url, "external")); + let sequencer = cfg.l2_sequencer.as_deref().and_then(|url| { + Node::new(url, "sequencer").or_else(|| { + warn!("failed to parse sequencer URL"); + None + }) + }); + let l2_health_checker = HealthChecker { + nodes: l2_nodes, + sequencer, + poll_interval: Duration::from_millis(cfg.poll_health_check_interval_ms), + grace_period: Duration::from_millis(cfg.l2_nodes_grace_period_ms), + metrics: m.clone(), + layer: "l2", + }; + services.push(Box::new(l2_health_checker)); + + // ── Flashblock validators ───────────────────────────── + let fb_urls = cfg + .flashblock_websocket_url + .iter() + .map(|u| (u.clone(), "websocket-proxy".to_string())) + .chain( + cfg.flashblock_rb_urls.iter().enumerate().map(|(i, u)| (u.clone(), format!("rb-{i}"))), + ) + .chain( + cfg.flashblock_builder_urls + .iter() + .enumerate() + .map(|(i, u)| (u.clone(), format!("builder-{i}"))), + ); + for (url, name) in fb_urls { + info!(url, name, "registering flashblock validator"); + let flashblock_validator = FlashblockValidator::new( + name, + url, + Arc::clone(l2), + Duration::from_millis(cfg.poll_l2_interval_ms), + m.clone(), + ); + services.push(Box::new(flashblock_validator)); + } + + // ── Mempool listener ────────────────────────────────── + match (cfg.geth_mempool_rpc_url.as_deref(), cfg.reth_mempool_rpc_url.as_deref()) { + (Some(g), Some(r)) => { + let geth = ProviderBuilder::new().connect_http(g.parse::()?); + let reth = ProviderBuilder::new().connect_http(r.parse::()?); + let mempool_listener = MempoolListenerService { + geth, + reth, + poll_interval: Duration::from_millis(cfg.poll_mempool_interval_ms), + metrics: m.clone(), + listener_name: "mempool-listener".to_string(), + }; + services.push(Box::new(mempool_listener)); + } + (None, None) => info!("no mempool RPC URLs provided, skipping"), + (None, _) => warn!("geth mempool URL missing, skipping mempool listener"), + (_, None) => warn!("reth mempool URL missing, skipping mempool listener"), + } + + // ── Divergence checker ──────────────────────────────── + if let (Some(g), Some(r)) = + (cfg.divergence_geth_node.as_deref(), cfg.divergence_reth_node.as_deref()) + { + let geth = ProviderBuilder::new().connect_http(g.parse::()?); + let reth = ProviderBuilder::new().connect_http(r.parse::()?); + let divergence_checker = DivergenceCheckerService { + geth, + reth, + poll_interval: Duration::from_millis(cfg.divergence_poll_interval_ms), + geth_grace_period: Duration::from_millis(cfg.divergence_geth_grace_period_ms), + metrics: m.clone(), + checker_name: "divergence-checker".to_string(), + }; + services.push(Box::new(divergence_checker)); + } else { + info!("divergence checker not configured, skipping"); + } + + // ── Snapshots ───────────────────────────────────────── + let snapshots_service = SnapshotsService { + buckets: cfg.snapshot_buckets.clone(), + poll_interval: Duration::from_secs(3600), + metrics: m.clone(), + }; + services.push(Box::new(snapshots_service)); + + Ok(services) +} + +/// Initialise `tracing` with the given level and format (`json` or `text`). +fn init_tracing(level: &str, format: &str) { + use tracing_subscriber::EnvFilter; + + let filter = EnvFilter::try_new(level).unwrap_or_else(|_| EnvFilter::new("info")); + + match format { + "json" => { + tracing_subscriber::fmt().with_env_filter(filter).json().init(); + } + _ => { + tracing_subscriber::fmt().with_env_filter(filter).init(); + } + } +} + +/// Wait for SIGINT / SIGTERM, cancel all tasks, and drain the join set. +async fn await_shutdown(cancel: CancellationToken, mut set: JoinSet<()>) { + use tokio::signal; + + tokio::select! { + _ = signal::ctrl_c() => { + info!("received SIGINT, shutting down"); + } + _ = async { + #[cfg(unix)] + { + let mut sigterm = signal::unix::signal(signal::unix::SignalKind::terminate()) + .expect("failed to register SIGTERM handler"); + sigterm.recv().await; + } + #[cfg(not(unix))] + { + std::future::pending::<()>().await; + } + } => { + info!("received SIGTERM, shutting down"); + } + } + + cancel.cancel(); + + // Give tasks 15 seconds to finish. + let deadline = tokio::time::Instant::now() + Duration::from_secs(15); + + loop { + tokio::select! { + result = set.join_next() => { + match result { + None => break, + Some(Ok(())) => {} + Some(Err(e)) => { + error!(error = %e, "task panicked during shutdown"); + } + } + } + _ = tokio::time::sleep_until(deadline) => { + warn!("timeout waiting for tasks to shut down"); + set.abort_all(); + break; + } + } + } +} diff --git a/crates/basectl/Cargo.toml b/crates/basectl/Cargo.toml index 3064f76..4bd30eb 100644 --- a/crates/basectl/Cargo.toml +++ b/crates/basectl/Cargo.toml @@ -24,7 +24,7 @@ alloy-rpc-types-eth = { workspace = true } alloy-eips = { workspace = true } op-alloy-network = { workspace = true } op-alloy-consensus = { workspace = true } -base-flashtypes = { workspace = true } +base-primitives = { workspace = true, features = ["flashblocks"] } url = { workspace = true } alloy-primitives = { workspace = true } alloy-sol-types = { workspace = true } diff --git a/crates/basectl/src/app/resources.rs b/crates/basectl/src/app/resources.rs index ac74277..55696a1 100644 --- a/crates/basectl/src/app/resources.rs +++ b/crates/basectl/src/app/resources.rs @@ -1,6 +1,6 @@ use std::collections::VecDeque; -use base_flashtypes::Flashblock; +use base_primitives::flashblocks::Flashblock; use tokio::sync::mpsc; use crate::{ diff --git a/crates/basectl/src/app/runner.rs b/crates/basectl/src/app/runner.rs index a596675..07a6359 100644 --- a/crates/basectl/src/app/runner.rs +++ b/crates/basectl/src/app/runner.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use base_flashtypes::Flashblock; +use base_primitives::flashblocks::Flashblock; use tokio::sync::mpsc; use super::{App, Resources, ViewId, views::create_view}; diff --git a/crates/basectl/src/rpc.rs b/crates/basectl/src/rpc.rs index 2957a4d..41c4713 100644 --- a/crates/basectl/src/rpc.rs +++ b/crates/basectl/src/rpc.rs @@ -5,7 +5,7 @@ use alloy_primitives::{Address, B256}; use alloy_provider::{Provider, ProviderBuilder}; use alloy_rpc_types_eth::{BlockNumberOrTag, TransactionTrait}; use anyhow::Result; -use base_flashtypes::Flashblock; +use base_primitives::flashblocks::Flashblock; use futures_util::{StreamExt, stream}; use op_alloy_network::Optimism; use tokio::sync::mpsc; diff --git a/crates/mempool-rebroadcaster/tests/e2e_tests.rs b/crates/mempool-rebroadcaster/tests/e2e_tests.rs index 5943136..8c6b659 100644 --- a/crates/mempool-rebroadcaster/tests/e2e_tests.rs +++ b/crates/mempool-rebroadcaster/tests/e2e_tests.rs @@ -20,7 +20,7 @@ async fn test_e2e_static_data() { let reth_mempool = load_static_mempool_content("testdata/reth_mempool.json") .expect("Failed to load reth mempool data"); - // Use constant network fees for testing (same as Go version) + // Use constant network fees for testing let base_fee = 0x2601ff_u128; // 0x2601ff let gas_price = 0x36daa7_u128; // 0x36daa7 diff --git a/crates/rpc-collector/Cargo.toml b/crates/rpc-collector/Cargo.toml new file mode 100644 index 0000000..cc5d00f --- /dev/null +++ b/crates/rpc-collector/Cargo.toml @@ -0,0 +1,57 @@ +[package] +name = "rpc-collector" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[lib] +path = "src/lib.rs" + +[dependencies] +# async +tokio = { workspace = true, features = ["rt-multi-thread", "macros", "signal", "sync", "time"] } +tokio-tungstenite.workspace = true +tokio-util = { workspace = true, features = ["rt"] } +async-trait.workspace = true +futures-util.workspace = true + +# alloy +alloy-primitives = { workspace = true, features = ["serde"] } +alloy-provider = { workspace = true, features = ["reqwest"] } +alloy-rpc-types-eth.workspace = true +alloy-consensus = { workspace = true, features = ["std"] } +alloy-sol-types.workspace = true +alloy-contract.workspace = true +alloy-rpc-client.workspace = true +alloy-transport-http.workspace = true +alloy-network.workspace = true +alloy-rlp.workspace = true +alloy-eips.workspace = true +alloy-network-primitives = { workspace = true } + +# op-alloy +op-alloy-consensus = { workspace = true, features = ["std"] } +op-alloy-rpc-types.workspace = true +op-alloy-network = { workspace = true, features = ["std"] } + +# metrics +cadence.workspace = true + +# web +axum = { workspace = true, features = ["tokio", "http1", "json"] } +reqwest.workspace = true + +# serialization +serde = { workspace = true } +serde_json = { workspace = true, features = ["std"] } + +# base +base-primitives = { workspace = true, features = ["flashblocks"] } + +# misc +tracing = { workspace = true, features = ["std"] } +anyhow = { workspace = true, features = ["std"] } +url.workspace = true diff --git a/crates/rpc-collector/src/bindings.rs b/crates/rpc-collector/src/bindings.rs new file mode 100644 index 0000000..8b46c22 --- /dev/null +++ b/crates/rpc-collector/src/bindings.rs @@ -0,0 +1,211 @@ +//! Contract bindings via `alloy::sol!`. +//! +//! Only the functions/events we actually call are included. +//! Also re-exports well-known OP Stack predeploy addresses. + +use alloy_primitives::{Address, address}; +use alloy_sol_types::sol; + +// ── OP Stack L2 Predeploy Addresses ───────────────────────── + +/// `SequencerFeeVault` predeploy. +pub const SEQUENCER_FEE_VAULT: Address = address!("4200000000000000000000000000000000000011"); +/// `BaseFeeVault` predeploy. +pub const BASE_FEE_VAULT: Address = address!("4200000000000000000000000000000000000019"); +/// `L1FeeVault` predeploy. +pub const L1_FEE_VAULT: Address = address!("420000000000000000000000000000000000001A"); +/// `L2ToL1MessagePasser` predeploy. +pub const L2_TO_L1_MSG_PASSER: Address = address!("4200000000000000000000000000000000000016"); +/// `L2StandardBridge` predeploy. +pub const L2_STANDARD_BRIDGE: Address = address!("4200000000000000000000000000000000000010"); +/// `L1Block` predeploy. +pub const L1_BLOCK: Address = address!("4200000000000000000000000000000000000015"); +/// `GasPriceOracle` predeploy. +pub const GAS_PRICE_ORACLE: Address = address!("420000000000000000000000000000000000000F"); + +// ── GasPriceOracle (L2 predeploy 0x420000000000000000000000000000000000000F) ── + +sol! { + #[sol(rpc)] + interface GasPriceOracle { + function l1BaseFee() external view returns (uint256); + function blobBaseFee() external view returns (uint256); + } +} + +// ── L2ToL1MessagePasser (L2 predeploy 0x4200000000000000000000000000000000000016) ── + +sol! { + #[sol(rpc)] + interface L2ToL1MessagePasser { + event MessagePassed( + uint256 indexed nonce, + address indexed sender, + address indexed target, + uint256 value, + uint256 gasLimit, + bytes data, + bytes32 withdrawalHash + ); + } +} + +// ── L2StandardBridge (L2 predeploy 0x4200000000000000000000000000000000000010) ── + +sol! { + #[sol(rpc)] + interface L2StandardBridge { + event WithdrawalInitiated( + address indexed l1Token, + address indexed l2Token, + address indexed from, + address to, + uint256 amount, + bytes extraData + ); + + event DepositFinalized( + address indexed l1Token, + address indexed l2Token, + address indexed from, + address to, + uint256 amount, + bytes extraData + ); + } +} + +// ── L1StandardBridge ── + +sol! { + #[sol(rpc)] + interface L1StandardBridge { + event ETHBridgeInitiated( + address indexed from, + address indexed to, + uint256 amount, + bytes extraData + ); + + event ERC20BridgeInitiated( + address indexed localToken, + address indexed remoteToken, + address indexed from, + address to, + uint256 amount, + bytes extraData + ); + + event ETHWithdrawalFinalized( + address indexed from, + address indexed to, + uint256 amount, + bytes extraData + ); + + event ERC20WithdrawalFinalized( + address indexed localToken, + address indexed remoteToken, + address indexed from, + address to, + uint256 amount, + bytes extraData + ); + } +} + +// ── OptimismPortal ── + +sol! { + #[sol(rpc)] + interface OptimismPortal { + event TransactionDeposited( + address indexed from, + address indexed to, + uint256 indexed version, + bytes opaqueData + ); + + event WithdrawalFinalized(bytes32 indexed withdrawalHash, bool success); + } +} + +// ── L2OutputOracle (legacy) ── + +sol! { + #[sol(rpc)] + interface L2OutputOracle { + function latestBlockNumber() external view returns (uint256); + } +} + +// ── DisputeGameFactory ── + +sol! { + #[sol(rpc)] + interface DisputeGameFactory { + function gameCount() external view returns (uint256); + + function gameAtIndex(uint256 _index) external view returns ( + uint32 gameType, + uint64 timestamp, + address proxy + ); + } +} + +// ── FaultDisputeGame (proxied via DisputeGameFactory) ── + +sol! { + #[sol(rpc)] + interface FaultDisputeGame { + function l2BlockNumber() external view returns (uint256); + } +} + +// ── SystemConfig (L1) ── + +sol! { + #[sol(rpc)] + interface SystemConfig { + function eip1559Elasticity() external view returns (uint32); + function eip1559Denominator() external view returns (uint32); + function scalar() external view returns (uint256); + function basefeeScalar() external view returns (uint32); + function blobbasefeeScalar() external view returns (uint32); + } +} + +// ── L1Block (L2 predeploy 0x4200000000000000000000000000000000000015) ── + +sol! { + #[sol(rpc)] + interface L1Block { + function baseFeeScalar() external view returns (uint32); + function blobBaseFeeScalar() external view returns (uint32); + function daFootprintGasScalar() external view returns (uint16); + } +} + +// ── FeeDisburser (L2) ── + +sol! { + #[sol(rpc)] + interface FeeDisburser { + event FeesDisbursed( + uint256 _disbursementTime, + uint256 _paidToOptimism, + uint256 _totalFeesDisbursed + ); + } +} + +// ── BalanceTracker (L1) ── + +sol! { + #[sol(rpc)] + interface BalanceTracker { + event ReceivedFunds(address indexed _sender, uint256 _amount); + event SentProfit(address indexed _profitWallet, bool indexed _success, uint256 _balanceSent); + } +} diff --git a/crates/rpc-collector/src/lib.rs b/crates/rpc-collector/src/lib.rs new file mode 100644 index 0000000..e1a7252 --- /dev/null +++ b/crates/rpc-collector/src/lib.rs @@ -0,0 +1,85 @@ +//! RPC Collector — collects `OPStack` chain metrics from L1 and L2 RPC endpoints. + +pub mod bindings; +pub mod metrics; +pub mod recorders; +pub mod services; +pub mod utils; + +/// Test helpers shared across unit test modules. +#[cfg(test)] +pub(crate) mod test_helpers { + use alloy_consensus::{Signed, TxEnvelope, TxLegacy, transaction::Recovered}; + use alloy_network_primitives::BlockTransactions; + use alloy_primitives::{Address, B256, Bytes, Signature, TxKind, U256}; + use alloy_rpc_types_eth::{ + Block as RpcBlock, Header as RpcHeader, Transaction as RpcTransaction, + }; + + /// The concrete RPC block type used throughout the crate. + pub(crate) type Block = + RpcBlock, RpcHeader>; + /// The concrete RPC header type. + #[allow(dead_code)] + pub(crate) type Header = RpcHeader; + /// The concrete RPC transaction type. + pub(crate) type Transaction = RpcTransaction; + + /// Build a minimal [`Block`] for testing with the given header fields. + /// + /// All other fields are left at their defaults. + pub(crate) fn make_block(number: u64, hash: B256, parent_hash: B256) -> Block { + let mut block: Block = Default::default(); + block.header.hash = hash; + block.header.inner.number = number; + block.header.inner.parent_hash = parent_hash; + block + } + + /// Build a minimal [`Block`] with the given transactions. + pub(crate) fn make_block_with_txs(number: u64, txs: Vec) -> Block { + let mut block: Block = Default::default(); + block.header.inner.number = number; + block.transactions = BlockTransactions::Full(txs); + block + } + + /// Build a minimal RPC [`Header`] for testing. + #[allow(dead_code)] + pub(crate) fn make_header(number: u64, hash: B256, parent_hash: B256) -> Header { + Header { + hash, + inner: alloy_consensus::Header { number, parent_hash, ..Default::default() }, + ..Default::default() + } + } + + /// Build a minimal RPC [`Transaction`] (legacy type) for testing. + /// + /// `from` is the sender, `to` is the recipient, and `input` is the calldata. + pub(crate) fn make_legacy_tx(from: Address, to: Address, input: Bytes) -> Transaction { + let tx_legacy = TxLegacy { + chain_id: Some(1), + nonce: 0, + gas_price: 1_000_000_000, + gas_limit: 21_000, + to: TxKind::Call(to), + value: U256::ZERO, + input, + }; + + let fake_sig = Signature::new(U256::from(1u64), U256::from(2u64), false); + let fake_hash = B256::default(); + let signed = Signed::new_unchecked(tx_legacy, fake_sig, fake_hash); + let envelope = TxEnvelope::Legacy(signed); + let recovered = Recovered::new_unchecked(envelope, from); + + RpcTransaction { + inner: recovered, + block_hash: None, + block_number: None, + transaction_index: None, + effective_gas_price: None, + } + } +} diff --git a/crates/rpc-collector/src/metrics.rs b/crates/rpc-collector/src/metrics.rs new file mode 100644 index 0000000..80d761a --- /dev/null +++ b/crates/rpc-collector/src/metrics.rs @@ -0,0 +1,219 @@ +//! `StatsD` metrics client wrapper and metric name constants. + +use std::{net::UdpSocket, sync::Arc}; + +use cadence::{ + BufferedUdpMetricSink, Counted, CountedExt, Gauged, Histogrammed, QueuingMetricSink, + StatsdClient, +}; + +// ── Metric name constants ──────────────────────────────────── + +// Block / gas metrics +pub const BLOCK_GAS_LIMIT: &str = "base.gas.limit"; +pub const BLOCK_GAS_USED: &str = "base.gas.used"; +pub const BLOCK_GAS_TARGET: &str = "base.gas.target"; +pub const BLOCK_BASE_GAS_FEE: &str = "base.gas.base.fee"; +pub const MIN_BASE_FEE: &str = "base.gas.minbasefee"; +pub const DA_FOOTPRINT: &str = "base.gas.dafootprint"; +pub const DA_FOOTPRINT_GAS_SCALAR: &str = "base.gas.dafootprintscalar"; +pub const ELASTICITY: &str = "base.elasticity"; + +// Collector progress +pub const L2_LATEST_BLOCK: &str = "base.l2.collector.block"; +pub const L1_LATEST_BLOCK: &str = "base.l1.collector.block"; + +// Transactions +pub const TRANSACTION_COUNT: &str = "base.transactions"; +pub const TRANSACTION_TYPE: &str = "base.txn.type"; +pub const TRANSACTION_GAS_PRICE: &str = "base.txn.gas.price"; +pub const TRANSACTION_GAS_PRICE_MAX: &str = "base.txn.gas.maximum"; +pub const TRANSACTION_MAX_PRIORITY_FEE: &str = "base.txn.gas.priorityfee"; +pub const TRANSACTION_TOTAL_PRI_FEES: &str = "base.txn.gas.totalpriorityfee"; +pub const TRANSACTION_L2_FEES: &str = "base.txn.l2.fees"; +pub const TRANSACTIONS_SUCCESS: &str = "base.l2.txn.success"; +pub const TRANSACTIONS_FAILED: &str = "base.l2.txn.failed"; +pub const EMPTY_BLOCKS: &str = "base.blocks.empty"; + +// Vault balances +pub const SEQUENCER_BALANCE: &str = "base.sequencer.vault.balance"; +pub const BASE_FEE_BALANCE: &str = "base.base.fee.vault.balance"; +pub const L1_FEE_BALANCE: &str = "base.l1.fee.vault.balance"; + +// Bridge events +pub const WITHDRAWAL_INITIATED_COUNT: &str = "base.withdrawal.initiated.count"; +pub const WITHDRAWAL_FINALIZED_COUNT: &str = "base.withdrawal.finalized.count"; +pub const DEPOSIT_INITIATED_COUNT: &str = "base.deposit.initiated.count"; +pub const DEPOSIT_FINALIZED_COUNT: &str = "base.deposit.finalized.count"; + +// Gas price oracle +pub const L1_BASE_FEE: &str = "base.l1.base.fee"; +pub const L1_BLOB_BASE_FEE: &str = "base.l1.blob.base.fee"; + +// Proposer / batcher +pub const PROPOSER_BALANCE: &str = "base.proposer.balance"; +pub const BATCH_BALANCE: &str = "base.batch.balance"; + +// Output oracle +pub const OUTPUT_ORACLE_LATEST_BLOCK: &str = "base.output.latest.block"; + +// Batch inbox +pub const BATCH_SENT_BY_NON_BATCH_SENDER: &str = "base.batch.inbox.unknown"; +pub const BATCH_SENT_BY_BATCH_SENDER: &str = "base.batch.inbox.batcher"; + +// Fee disburser / balance tracker +pub const FEES_DISBURSED_COUNT: &str = "base.fees.disbursed.count"; +pub const RECEIVED_FUNDS_COUNT: &str = "base.received.funds.count"; +pub const SENT_PROFIT_COUNT: &str = "base.sent.profit.count"; + +// Reorg +pub const REORG_DEPTH: &str = "base.reorg.depth"; + +// Snapshots +pub const SNAPSHOT_ERROR: &str = "base.snapshot.error"; +pub const SNAPSHOT_SUCCESS: &str = "base.snapshot.time"; +pub const SNAPSHOT_AGE: &str = "base.snapshot.age"; +pub const SNAPSHOT_SIZE: &str = "base.snapshot.size"; + +// Flashblocks +pub const FLASHBLOCK_HASH_MISMATCH: &str = "base.flashblock.hash.mismatch"; +pub const FLASHBLOCK_VALIDATION_SUCCESS: &str = "base.flashblock.validation.success"; +pub const FLASHBLOCK_MISSING_INDICES: &str = "base.flashblock.missing.indices"; +pub const FLASHBLOCK_TOTAL_RECEIVED: &str = "base.flashblock.total.received"; +pub const FLASHBLOCK_NO_DATA_RECEIVED: &str = "base.flashblock.no.data.received"; +pub const FLASHBLOCK_SUCCESS: &str = "base.flashblock.success"; +pub const FLASHBLOCK_REORG: &str = "base.flashblock.reorg"; +pub const FLASHBLOCK_TX_REORGED: &str = "base.flashblock.tx.reorged"; +pub const FLASHBLOCK_TX_INCLUDED: &str = "base.flashblock.tx.included"; +pub const FLASHBLOCK_TX_TOTAL: &str = "base.flashblock.tx.total"; +pub const FLASHBLOCK_TX_ORDER_VIOLATION: &str = "base.flashblock.tx.order.violation"; +pub const FLASHBLOCK_TX_ORDER_SUCCESS: &str = "base.flashblock.tx.order.success"; +pub const FLASHBLOCK_PRIORITY_FEE_MIN: &str = "base.flashblock.priorityfee.min"; +pub const FLASHBLOCK_PRIORITY_FEE_TX_COUNT: &str = "base.flashblock.priorityfee.tx.count"; +pub const FLASHBLOCK_PRIORITY_FEE_TIP: &str = "base.flashblock.priorityfee.tip"; + +// Mempool +pub const MEMPOOL_PENDING_COUNT: &str = "base.mempool.pending.count"; +pub const MEMPOOL_QUEUED_COUNT: &str = "base.mempool.queued.count"; +pub const MEMPOOL_ERROR: &str = "base.mempool.error"; +pub const MEMPOOL_COLLECTION_SUCCESS: &str = "base.mempool.collection.success"; +pub const MEMPOOL_RECLASSIFIED_COUNT: &str = "base.mempool.reclassified.count"; +pub const MEMPOOL_ORIGINAL_PENDING_COUNT: &str = "base.mempool.original.pending.count"; +pub const MEMPOOL_PENDING_DIFF: &str = "base.mempool.pending.diff"; +pub const MEMPOOL_QUEUED_DIFF: &str = "base.mempool.queued.diff"; +pub const MEMPOOL_TOTAL_DIFF: &str = "base.mempool.total.diff"; + +// Node health +pub const NODE_LATEST_BLOCK: &str = "base.node.latest.num"; +pub const NODE_LATEST_TIME: &str = "base.node.latest.time"; +pub const NODE_HEALTHY: &str = "base.node.healthy"; +pub const NODE_ERROR: &str = "base.node.error"; +pub const NODE_STALL: &str = "base.node.stall"; +pub const NODE_RATE_LIMITED: &str = "base.node.ratelimited"; +pub const SEQUENCER_LATEST_BLOCK: &str = "base.sequencer.latest.num"; +pub const SEQUENCER_DELTA: &str = "base.node.sequencer.delta"; + +// Divergence +pub const DIVERGENCE_BLOCK_PROCESSED: &str = "base.divergence.block.processed"; +pub const DIVERGENCE_CROSS_GROUP_DETECTED: &str = "base.divergence.cross.group.detected"; +pub const DIVERGENCE_GETH_TIMEOUT: &str = "base.divergence.geth.timeout"; +pub const DIVERGENCE_NODE_ERROR: &str = "base.divergence.node.error"; + +// ── StatsD client wrapper ──────────────────────────────────── + +/// Thin wrapper around [`cadence::StatsdClient`]. +/// +/// For untagged metrics, use the inherent `gauge`, `count`, and `histogram` +/// methods. For tagged metrics, use the `*_with_tags` convenience methods. +#[derive(Clone, Debug)] +pub struct Metrics { + inner: Arc, +} + +impl Metrics { + /// Create a new [`Metrics`] client connected to the given `StatsD` endpoint. + pub fn new(host: &str, port: u16, prefix: &str) -> anyhow::Result { + let socket = UdpSocket::bind("0.0.0.0:0")?; + socket.set_nonblocking(true)?; + let addr = format!("{host}:{port}"); + let udp_sink = BufferedUdpMetricSink::from(addr.as_str(), socket)?; + let queuing_sink = QueuingMetricSink::from(udp_sink); + let client = StatsdClient::from_sink(prefix, queuing_sink); + Ok(Self { inner: Arc::new(client) }) + } + + /// Create a no-op metrics client that silently drops all metrics. + pub fn noop() -> Self { + Self { inner: Arc::new(StatsdClient::from_sink("", cadence::NopMetricSink)) } + } + + // ── Untagged metric helpers ──────────────────────────── + + pub fn gauge(&self, key: &str, value: f64) -> cadence::MetricResult { + self.inner.gauge(key, value) + } + + pub fn count(&self, key: &str, value: i64) -> cadence::MetricResult { + self.inner.count(key, value) + } + + pub fn histogram(&self, key: &str, value: f64) -> cadence::MetricResult { + self.inner.histogram(key, value) + } + + // ── Tagged convenience helpers ─────────────────────────── + + pub fn gauge_with_tags(&self, name: &str, value: f64, tags: &[(&str, &str)]) { + let mut builder = self.inner.gauge_with_tags(name, value); + for &(k, v) in tags { + builder = builder.with_tag(k, v); + } + builder.send(); + } + + pub fn count_with_tags(&self, name: &str, value: i64, tags: &[(&str, &str)]) { + let mut builder = self.inner.count_with_tags(name, value); + for &(k, v) in tags { + builder = builder.with_tag(k, v); + } + builder.send(); + } + + pub fn incr_with_tags(&self, name: &str, tags: &[(&str, &str)]) { + let mut builder = self.inner.incr_with_tags(name); + for &(k, v) in tags { + builder = builder.with_tag(k, v); + } + builder.send(); + } + + pub fn histogram_with_tags(&self, name: &str, value: f64, tags: &[(&str, &str)]) { + let mut builder = self.inner.histogram_with_tags(name, value); + for &(k, v) in tags { + builder = builder.with_tag(k, v); + } + builder.send(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn noop_does_not_panic() { + let m = Metrics::noop(); + + // Untagged helpers + let _ = m.gauge("test.gauge", 42.0); + let _ = m.count("test.count", 1); + let _ = m.histogram("test.histogram", 2.78); + + // Tagged helpers + let tags = [("env", "test"), ("layer", "l2")]; + m.gauge_with_tags("test.gauge.tagged", 1.0, &tags); + m.count_with_tags("test.count.tagged", 1, &tags); + m.incr_with_tags("test.incr.tagged", &tags); + m.histogram_with_tags("test.histogram.tagged", 2.0, &tags); + } +} diff --git a/crates/rpc-collector/src/recorders/batches_info.rs b/crates/rpc-collector/src/recorders/batches_info.rs new file mode 100644 index 0000000..4871847 --- /dev/null +++ b/crates/rpc-collector/src/recorders/batches_info.rs @@ -0,0 +1,122 @@ +//! Batch inbox recorder — counts data posted to the batch inbox address. + +use alloy_consensus::Transaction as _; +use alloy_eips::Typed2718; +use alloy_network::TransactionResponse as _; +use alloy_primitives::Address; +use alloy_rpc_types_eth::Block; +use async_trait::async_trait; + +use crate::{ + metrics::{self, Metrics}, + recorders::MetricRecorder, +}; + +/// Size of a single blob: 4096 field elements × 32 bytes. +const BLOB_SIZE: usize = 4096 * 32; + +/// Reports the volume of data sent to the batch inbox, split by whether the +/// sender is the canonical batcher or not. +#[derive(Debug)] +pub struct BatchesInfoRecorder { + batch_inbox: Address, + batch_sender: Address, +} + +impl BatchesInfoRecorder { + pub const fn new(batch_inbox: Address, batch_sender: Address) -> Self { + Self { batch_inbox, batch_sender } + } +} + +#[async_trait] +impl MetricRecorder for BatchesInfoRecorder { + fn name(&self) -> &'static str { + "batches_info" + } + + async fn record(&self, block: &Block, m: &Metrics) -> anyhow::Result<()> { + for tx in block.transactions.txns() { + let to = match tx.to() { + Some(addr) => addr, + None => continue, + }; + + if to != self.batch_inbox { + continue; + } + + let data_len = if tx.ty() == alloy_consensus::TxType::Eip4844 as u8 { + tx.blob_versioned_hashes() + .map_or(0, |h: &[alloy_primitives::B256]| h.len() * BLOB_SIZE) + } else { + tx.input().len() + }; + + let from = tx.from(); + if from == self.batch_sender { + let _ = m.count(metrics::BATCH_SENT_BY_BATCH_SENDER, data_len as i64); + } else { + let _ = m.count(metrics::BATCH_SENT_BY_NON_BATCH_SENDER, data_len as i64); + } + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use alloy_primitives::Bytes; + + use super::*; + use crate::test_helpers::{make_block_with_txs, make_legacy_tx}; + + const BATCH_INBOX: Address = Address::new([0xBB; 20]); + const BATCH_SENDER: Address = Address::new([0xCC; 20]); + const RANDOM_SENDER: Address = Address::new([0xDD; 20]); + const RANDOM_ADDR: Address = Address::new([0xEE; 20]); + + fn recorder() -> BatchesInfoRecorder { + BatchesInfoRecorder::new(BATCH_INBOX, BATCH_SENDER) + } + + #[tokio::test] + async fn counts_batcher_calldata() { + let r = recorder(); + let m = Metrics::noop(); + let input = Bytes::from(vec![0u8; 100]); + let tx = make_legacy_tx(BATCH_SENDER, BATCH_INBOX, input); + let block = make_block_with_txs(1, vec![tx]); + // Should not error; metric is emitted via BATCH_SENT_BY_BATCH_SENDER. + assert!(r.record(&block, &m).await.is_ok()); + } + + #[tokio::test] + async fn counts_non_batcher_calldata() { + let r = recorder(); + let m = Metrics::noop(); + let input = Bytes::from(vec![0u8; 50]); + let tx = make_legacy_tx(RANDOM_SENDER, BATCH_INBOX, input); + let block = make_block_with_txs(1, vec![tx]); + assert!(r.record(&block, &m).await.is_ok()); + } + + #[tokio::test] + async fn ignores_tx_to_other_addresses() { + let r = recorder(); + let m = Metrics::noop(); + let tx = make_legacy_tx(BATCH_SENDER, RANDOM_ADDR, Bytes::from(vec![0u8; 10])); + let block = make_block_with_txs(1, vec![tx]); + // Should succeed but emit no batch metrics. + assert!(r.record(&block, &m).await.is_ok()); + } + + #[tokio::test] + async fn empty_block_no_metrics() { + let r = recorder(); + let m = Metrics::noop(); + let block = make_block_with_txs(1, vec![]); + assert!(r.record(&block, &m).await.is_ok()); + } +} diff --git a/crates/rpc-collector/src/recorders/dispute_game_factory.rs b/crates/rpc-collector/src/recorders/dispute_game_factory.rs new file mode 100644 index 0000000..8a28c72 --- /dev/null +++ b/crates/rpc-collector/src/recorders/dispute_game_factory.rs @@ -0,0 +1,63 @@ +//! Dispute game factory recorder — reports the latest L2 block number from the +//! most recent dispute game. + +use std::sync::Arc; + +use alloy_primitives::Address; +use alloy_provider::Provider; +use alloy_rpc_types_eth::Block; +use async_trait::async_trait; + +use crate::{ + bindings::{DisputeGameFactory, FaultDisputeGame}, + metrics::{self, Metrics}, + recorders::MetricRecorder, +}; + +/// Reports the latest L2 block from the newest dispute game. +#[derive(Debug)] +pub struct DisputeGameFactoryRecorder

{ + provider: Arc

, + address: Address, +} + +impl

DisputeGameFactoryRecorder

{ + pub const fn new(provider: Arc

, address: Address) -> Self { + Self { provider, address } + } +} + +#[async_trait] +impl MetricRecorder for DisputeGameFactoryRecorder

{ + fn name(&self) -> &'static str { + "dispute_game_factory" + } + + async fn record(&self, block: &Block, m: &Metrics) -> anyhow::Result<()> { + let block_id = alloy_eips::BlockId::Number(block.header.number.into()); + let factory = DisputeGameFactory::new(self.address, &*self.provider); + + // 1. Get total game count. + let count_ret = factory.gameCount().block(block_id).call().await?; + let game_count: u64 = count_ret.try_into().unwrap_or(0); + if game_count == 0 { + return Ok(()); + } + + // 2. Get the latest game. + let game_ret = factory + .gameAtIndex(alloy_primitives::U256::from(game_count - 1)) + .block(block_id) + .call() + .await?; + + // 3. Query the fault dispute game proxy for l2BlockNumber. + let fault_game = FaultDisputeGame::new(game_ret.proxy, &*self.provider); + let bn_ret = fault_game.l2BlockNumber().block(block_id).call().await?; + let l2_block: u128 = bn_ret.try_into().unwrap_or(u128::MAX); + + let _ = m.gauge(metrics::OUTPUT_ORACLE_LATEST_BLOCK, l2_block as f64); + + Ok(()) + } +} diff --git a/crates/rpc-collector/src/recorders/eth_balance.rs b/crates/rpc-collector/src/recorders/eth_balance.rs new file mode 100644 index 0000000..f645ced --- /dev/null +++ b/crates/rpc-collector/src/recorders/eth_balance.rs @@ -0,0 +1,61 @@ +//! ETH and contract balance recorder. + +use std::sync::Arc; + +use alloy_primitives::{Address, Bytes, U256, utils::Unit}; +use alloy_provider::Provider; +use alloy_rpc_types_eth::{Block, TransactionRequest}; +use async_trait::async_trait; + +use crate::{metrics::Metrics, recorders::MetricRecorder, utils::wei_to_unit}; + +/// Reports the ETH or contract balance of an address as a gauge. +#[derive(Debug)] +pub struct EthBalanceRecorder

{ + provider: Arc

, + address: Address, + metric_name: String, + calldata: Option>, +} + +impl

EthBalanceRecorder

{ + /// Create a recorder for a plain ETH balance. + pub const fn eth(provider: Arc

, address: Address, metric_name: String) -> Self { + Self { provider, address, metric_name, calldata: None } + } + + /// Create a recorder for a contract balance (calls the contract with `calldata`). + pub const fn contract( + provider: Arc

, + address: Address, + metric_name: String, + calldata: Vec, + ) -> Self { + Self { provider, address, metric_name, calldata: Some(calldata) } + } +} + +#[async_trait] +impl MetricRecorder for EthBalanceRecorder

{ + fn name(&self) -> &'static str { + "eth_balance" + } + + async fn record(&self, block: &Block, m: &Metrics) -> anyhow::Result<()> { + let block_id = alloy_eips::BlockId::Number(block.header.number.into()); + + let balance = match &self.calldata { + None => self.provider.get_balance(self.address).block_id(block_id).await?, + Some(cd) => { + let tx = TransactionRequest::default() + .to(self.address) + .input(Bytes::copy_from_slice(cd).into()); + let result = self.provider.call(tx).block(block_id).await?; + U256::from_be_slice(&result) + } + }; + + let _ = m.gauge(&self.metric_name, wei_to_unit(balance, Unit::ETHER)); + Ok(()) + } +} diff --git a/crates/rpc-collector/src/recorders/event_counter.rs b/crates/rpc-collector/src/recorders/event_counter.rs new file mode 100644 index 0000000..63729a6 --- /dev/null +++ b/crates/rpc-collector/src/recorders/event_counter.rs @@ -0,0 +1,63 @@ +//! Generic event counter recorder — counts contract events in a block range. + +use std::{fmt, sync::Arc}; + +use alloy_primitives::{Address, FixedBytes}; +use alloy_provider::Provider; +use alloy_rpc_types_eth::{Block, Filter}; +use async_trait::async_trait; + +use crate::{metrics::Metrics, recorders::MetricRecorder}; + +/// Reports the count of a specific event emitted by a contract. +pub struct EventCounterRecorder

{ + provider: Arc

, + address: Address, + event_topic: FixedBytes<32>, + metric_name: String, +} + +impl

fmt::Debug for EventCounterRecorder

{ + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("EventCounterRecorder") + .field("address", &self.address) + .field("event_topic", &self.event_topic) + .field("metric_name", &self.metric_name) + .finish() + } +} + +impl

EventCounterRecorder

{ + pub const fn new( + provider: Arc

, + address: Address, + event_topic: FixedBytes<32>, + metric_name: String, + ) -> Self { + Self { provider, address, event_topic, metric_name } + } +} + +#[async_trait] +impl MetricRecorder for EventCounterRecorder

{ + fn name(&self) -> &'static str { + "event_counter" + } + + async fn record(&self, block: &Block, m: &Metrics) -> anyhow::Result<()> { + let block_num = block.header.number; + + let filter = Filter::new() + .address(self.address) + .event_signature(self.event_topic) + .from_block(block_num) + .to_block(block_num); + + let count = self.provider.get_logs(&filter).await?.len(); + if count > 0 { + let _ = m.count(&self.metric_name, count as i64); + } + + Ok(()) + } +} diff --git a/crates/rpc-collector/src/recorders/gas_price_oracle.rs b/crates/rpc-collector/src/recorders/gas_price_oracle.rs new file mode 100644 index 0000000..376b1aa --- /dev/null +++ b/crates/rpc-collector/src/recorders/gas_price_oracle.rs @@ -0,0 +1,62 @@ +//! Gas price oracle recorder — fetches L1 base fee and blob base fee from the +//! `GasPriceOracle` L2 predeploy. + +use std::sync::Arc; + +use alloy_provider::Provider; +use alloy_rpc_types_eth::Block; +use async_trait::async_trait; + +use crate::{ + bindings::{self, GasPriceOracle}, + metrics::{self, Metrics}, + recorders::MetricRecorder, +}; + +/// Reports L1 base fee and blob base fee from the `GasPriceOracle` contract. +#[derive(Debug)] +pub struct GasPriceOracleRecorder

{ + provider: Arc

, +} + +impl

GasPriceOracleRecorder

{ + pub const fn new(provider: Arc

) -> Self { + Self { provider } + } +} + +#[async_trait] +impl MetricRecorder for GasPriceOracleRecorder

{ + fn name(&self) -> &'static str { + "gas_price_oracle" + } + + async fn record(&self, block: &Block, m: &Metrics) -> anyhow::Result<()> { + let block_id = alloy_eips::BlockId::Number(block.header.number.into()); + let oracle = GasPriceOracle::new(bindings::GAS_PRICE_ORACLE, &*self.provider); + + // L1 base fee + match oracle.l1BaseFee().block(block_id).call().await { + Ok(ret) => { + let fee: u128 = ret.try_into().unwrap_or(u128::MAX); + let _ = m.gauge(metrics::L1_BASE_FEE, fee as f64); + } + Err(e) => { + tracing::warn!(error = %e, "failed to call GasPriceOracle.l1BaseFee"); + } + } + + // Blob base fee + match oracle.blobBaseFee().block(block_id).call().await { + Ok(ret) => { + let fee: u128 = ret.try_into().unwrap_or(u128::MAX); + let _ = m.gauge(metrics::L1_BLOB_BASE_FEE, fee as f64); + } + Err(e) => { + tracing::warn!(error = %e, "failed to call GasPriceOracle.blobBaseFee"); + } + } + + Ok(()) + } +} diff --git a/crates/rpc-collector/src/recorders/l2_block_info.rs b/crates/rpc-collector/src/recorders/l2_block_info.rs new file mode 100644 index 0000000..0f1f0ce --- /dev/null +++ b/crates/rpc-collector/src/recorders/l2_block_info.rs @@ -0,0 +1,192 @@ +//! L2 block info recorder — the richest per-block metric recorder. +//! +//! Emits gas usage/limits/target, base fee, transaction type breakdown, +//! priority fees, DA footprint, empty block counts and transaction statuses. + +use std::sync::Arc; + +use alloy_consensus::Transaction; +use alloy_eips::Typed2718; +use alloy_network::TransactionResponse; +use alloy_primitives::{Address, U256, utils::Unit}; +use alloy_provider::Provider; +use alloy_rpc_types_eth::Block; +use async_trait::async_trait; +use tokio::task::JoinSet; +use tracing::{error, warn}; + +use crate::{ + bindings, + metrics::{self, Metrics}, + recorders::MetricRecorder, + utils::{self, wei_to_unit}, +}; + +/// Reports L2 block-level metrics: gas, fees, transactions, DA footprint. + +#[derive(Debug)] +pub struct L2BlockInfoRecorder

{ + provider: Arc

, + system_config_address: Address, +} + +impl

L2BlockInfoRecorder

{ + pub const fn new(provider: Arc

, system_config_address: Address) -> Self { + Self { provider, system_config_address } + } +} + +#[async_trait] +impl MetricRecorder for L2BlockInfoRecorder

{ + fn name(&self) -> &'static str { + "l2_block_info" + } + + async fn record(&self, block: &Block, m: &Metrics) -> anyhow::Result<()> { + let block_num = block.header.number; + let gas_limit = block.header.gas_limit; + let gas_used = block.header.gas_used; + let base_fee = block.header.base_fee_per_gas.unwrap_or(0); + + let _ = m.gauge(metrics::BLOCK_GAS_LIMIT, gas_limit as f64); + let _ = m.histogram(metrics::BLOCK_GAS_USED, gas_used as f64); + let _ = m.histogram(metrics::BLOCK_BASE_GAS_FEE, base_fee as f64); + let tx_count = block.transactions.len(); + let _ = m.count(metrics::TRANSACTION_COUNT, tx_count as i64); + + // Extract minBaseFee from ExtraData (Jovian format: bytes 9..17). + let extra = &block.header.extra_data; + if extra.len() >= 17 { + let min_base_fee = u64::from_be_bytes(extra[9..17].try_into().unwrap_or_default()); + let _ = m.gauge(metrics::MIN_BASE_FEE, min_base_fee as f64); + } + + // DA footprint from blob_gas_used field (Jovian format). + if let Some(blob_gas_used) = block.header.blob_gas_used { + let _ = m.gauge(metrics::DA_FOOTPRINT, blob_gas_used as f64); + } + + // Fetch DA footprint gas scalar from L1Block predeploy. + let l1_block = bindings::L1Block::new(bindings::L1_BLOCK, &*self.provider); + match l1_block.daFootprintGasScalar().call().await { + Ok(ret) => { + let _ = m.gauge(metrics::DA_FOOTPRINT_GAS_SCALAR, ret as f64); + } + Err(e) => { + warn!(error = %e, "failed to get DA footprint gas scalar"); + } + } + + // Empty block detection. + if tx_count <= 1 { + warn!(block = block_num, tx_count, "empty block detected"); + let _ = m.count(metrics::EMPTY_BLOCKS, 1); + } + + // EIP-1559 elasticity + gas target (from L1 SystemConfig). + let sys_config = bindings::SystemConfig::new(self.system_config_address, &*self.provider); + match sys_config.eip1559Elasticity().call().await { + Ok(elasticity) => { + if elasticity > 0 { + let gas_target = gas_limit as f64 / f64::from(elasticity); + let _ = m.gauge(metrics::BLOCK_GAS_TARGET, gas_target); + let _ = m.gauge(metrics::ELASTICITY, f64::from(elasticity)); + } + } + Err(e) => { + warn!(error = %e, "failed to get EIP-1559 elasticity"); + } + } + + // Per-transaction metrics. + for tx in block.transactions.txns() { + let tx_type = tx.ty(); + let type_str = tx_type.to_string(); + let tags: [(&str, &str); 1] = [("txnType", type_str.as_str())]; + + m.count_with_tags(metrics::TRANSACTION_TYPE, i64::from(tx_type), &tags); + + let gas_price = Transaction::gas_price(tx).unwrap_or(0); + let tip_cap = tx.max_priority_fee_per_gas().unwrap_or(0); + let effective_tip = + tip_cap.min(Transaction::max_fee_per_gas(tx).saturating_sub(base_fee as u128)); + let effective_price = base_fee as u128 + effective_tip; + + m.histogram_with_tags( + metrics::TRANSACTION_GAS_PRICE_MAX, + wei_to_unit(U256::from(gas_price), Unit::GWEI), + &tags, + ); + m.histogram_with_tags( + metrics::TRANSACTION_MAX_PRIORITY_FEE, + wei_to_unit(U256::from(tip_cap), Unit::GWEI), + &tags, + ); + m.histogram_with_tags( + metrics::TRANSACTION_GAS_PRICE, + wei_to_unit(U256::from(effective_price), Unit::GWEI), + &tags, + ); + } + + // Receipt-based metrics (tx success/fail, L1 DA fees, priority fees). + self.record_transaction_status(block, m).await; + + Ok(()) + } +} + +impl L2BlockInfoRecorder

{ + async fn record_transaction_status(&self, block: &Block, m: &Metrics) { + let base_fee = block.header.base_fee_per_gas.unwrap_or(0); + let mut set = JoinSet::new(); + + for tx in block.transactions.txns() { + let tx_hash = tx.tx_hash(); + let provider = Arc::clone(&self.provider); + let metrics = m.clone(); + let tip_cap = tx.max_priority_fee_per_gas().unwrap_or(0); + let max_fee = Transaction::max_fee_per_gas(tx); + let tx_type = tx.ty(); + + set.spawn(async move { + let receipt = match provider.get_transaction_receipt(tx_hash).await { + Ok(Some(r)) => r, + Ok(None) => return, + Err(e) => { + error!(error = %e, "error fetching receipt"); + return; + } + }; + + // Skip system transactions (type 126). + if tx_type != utils::DEPOSIT_TX_TYPE { + // Compute L2 fee. + let effective_gas_price = receipt.effective_gas_price; + let gas_used = receipt.gas_used as u128; + let l2_fee = effective_gas_price * gas_used; + let _ = metrics.histogram( + metrics::TRANSACTION_L2_FEES, + wei_to_unit(U256::from(l2_fee), Unit::GWEI), + ); + + // Priority fees. + let effective_tip = tip_cap.min(max_fee.saturating_sub(base_fee as u128)); + let tx_priority_fees = effective_tip * gas_used; + let _ = metrics.gauge( + metrics::TRANSACTION_TOTAL_PRI_FEES, + wei_to_unit(U256::from(tx_priority_fees), Unit::GWEI), + ); + } + + if receipt.status() { + let _ = metrics.count(metrics::TRANSACTIONS_SUCCESS, 1); + } else { + let _ = metrics.count(metrics::TRANSACTIONS_FAILED, 1); + } + }); + } + + while set.join_next().await.is_some() {} + } +} diff --git a/crates/rpc-collector/src/recorders/mod.rs b/crates/rpc-collector/src/recorders/mod.rs new file mode 100644 index 0000000..fed7669 --- /dev/null +++ b/crates/rpc-collector/src/recorders/mod.rs @@ -0,0 +1,30 @@ +//! Metric recorder trait and implementations. +//! +//! Each recorder receives a block and emits `StatsD` metrics for a specific +//! aspect of the chain (balances, events, gas prices, etc.). + +use alloy_rpc_types_eth::Block; +use async_trait::async_trait; + +use crate::metrics::Metrics; + +/// A metric recorder receives a block and emits `StatsD` metrics. +/// +/// Implementations should be cheap to clone (wrap heavy state in [`Arc`]). +#[async_trait] +pub trait MetricRecorder: Send + Sync + 'static { + /// Human-readable name, used in error logs. + fn name(&self) -> &'static str; + + /// Record metrics for the given block. + async fn record(&self, block: &Block, metrics: &Metrics) -> anyhow::Result<()>; +} + +pub mod batches_info; +pub mod dispute_game_factory; +pub mod eth_balance; +pub mod event_counter; +pub mod gas_price_oracle; +pub mod l2_block_info; +pub mod output_oracle; +pub mod reorg_detector; diff --git a/crates/rpc-collector/src/recorders/output_oracle.rs b/crates/rpc-collector/src/recorders/output_oracle.rs new file mode 100644 index 0000000..4f03cdd --- /dev/null +++ b/crates/rpc-collector/src/recorders/output_oracle.rs @@ -0,0 +1,45 @@ +//! L2 Output Oracle recorder — reports the latest L2 block number posted on L1. + +use std::sync::Arc; + +use alloy_primitives::Address; +use alloy_provider::Provider; +use alloy_rpc_types_eth::Block; +use async_trait::async_trait; + +use crate::{ + bindings::L2OutputOracle, + metrics::{self, Metrics}, + recorders::MetricRecorder, +}; + +/// Reports the latest block number from the `L2OutputOracle` contract. +#[derive(Debug)] +pub struct OutputOracleRecorder

{ + provider: Arc

, + address: Address, +} + +impl

OutputOracleRecorder

{ + pub const fn new(provider: Arc

, address: Address) -> Self { + Self { provider, address } + } +} + +#[async_trait] +impl MetricRecorder for OutputOracleRecorder

{ + fn name(&self) -> &'static str { + "output_oracle" + } + + async fn record(&self, block: &Block, m: &Metrics) -> anyhow::Result<()> { + let block_id = alloy_eips::BlockId::Number(block.header.number.into()); + let oracle = L2OutputOracle::new(self.address, &*self.provider); + + let ret = oracle.latestBlockNumber().block(block_id).call().await?; + let block_number: u128 = ret.try_into().unwrap_or(u128::MAX); + let _ = m.gauge(metrics::OUTPUT_ORACLE_LATEST_BLOCK, block_number as f64); + + Ok(()) + } +} diff --git a/crates/rpc-collector/src/recorders/reorg_detector.rs b/crates/rpc-collector/src/recorders/reorg_detector.rs new file mode 100644 index 0000000..bc0eaf5 --- /dev/null +++ b/crates/rpc-collector/src/recorders/reorg_detector.rs @@ -0,0 +1,163 @@ +//! Reorg detector — tracks chain reorganisations by comparing parent hashes. + +use std::collections::VecDeque; + +use alloy_primitives::B256; +use alloy_rpc_types_eth::Block; +use async_trait::async_trait; +use tokio::sync::Mutex; +use tracing::warn; + +use crate::{ + metrics::{self, Metrics}, + recorders::MetricRecorder, +}; + +const MAX_BLOCK_HASH_LEN: usize = 10_000; + +/// Detects reorgs by keeping a sliding window of recent block hashes. +#[derive(Debug)] +pub struct ReorgDetectorRecorder { + state: Mutex, +} + +#[derive(Debug)] +struct ReorgState { + parent_hash: Option, + block_hashes: VecDeque, +} + +impl Default for ReorgDetectorRecorder { + fn default() -> Self { + Self::new() + } +} + +impl ReorgDetectorRecorder { + pub fn new() -> Self { + Self { state: Mutex::new(ReorgState { parent_hash: None, block_hashes: VecDeque::new() }) } + } +} + +#[async_trait] +impl MetricRecorder for ReorgDetectorRecorder { + fn name(&self) -> &'static str { + "reorg_detector" + } + + async fn record(&self, block: &Block, m: &Metrics) -> anyhow::Result<()> { + let block_hash = block.header.hash; + let parent_hash = block.header.parent_hash; + let block_num = block.header.number; + + let mut state = self.state.lock().await; + + // Check for reorg. + if let Some(prev_hash) = state.parent_hash + && parent_hash != prev_hash + { + // Reorg detected. Estimate depth. + let depth = state.block_hashes.len() as u64; + warn!( + block = block_num, + depth, + new_parent = %parent_hash, + old_parent = %prev_hash, + "reorg detected" + ); + let _ = m.histogram(metrics::REORG_DEPTH, depth as f64); + } + + state.parent_hash = Some(block_hash); + state.block_hashes.push_back(block_hash); + if state.block_hashes.len() > MAX_BLOCK_HASH_LEN { + state.block_hashes.pop_front(); + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_helpers::make_block; + + fn hash(n: u8) -> B256 { + let mut bytes = [0u8; 32]; + bytes[31] = n; + B256::from(bytes) + } + + #[tokio::test] + async fn first_block_never_reorgs() { + let detector = ReorgDetectorRecorder::new(); + let m = Metrics::noop(); + + let block = make_block(1, hash(1), hash(0)); + // First block should never produce an error or reorg. + assert!(detector.record(&block, &m).await.is_ok()); + } + + #[tokio::test] + async fn no_reorg_sequential_blocks() { + let detector = ReorgDetectorRecorder::new(); + let m = Metrics::noop(); + + // Block 1: hash=1, parent=0 + let b1 = make_block(1, hash(1), hash(0)); + detector.record(&b1, &m).await.unwrap(); + + // Block 2: hash=2, parent=1 (correct chain) + let b2 = make_block(2, hash(2), hash(1)); + detector.record(&b2, &m).await.unwrap(); + + // Block 3: hash=3, parent=2 (correct chain) + let b3 = make_block(3, hash(3), hash(2)); + detector.record(&b3, &m).await.unwrap(); + + // Verify the state is consistent. + let state = detector.state.lock().await; + assert_eq!(state.parent_hash, Some(hash(3))); + assert_eq!(state.block_hashes.len(), 3); + } + + #[tokio::test] + async fn reorg_detected_on_parent_mismatch() { + let detector = ReorgDetectorRecorder::new(); + let m = Metrics::noop(); + + // Block 1 + let b1 = make_block(1, hash(1), hash(0)); + detector.record(&b1, &m).await.unwrap(); + + // Block 2 with WRONG parent (hash(99) instead of hash(1)) → reorg + let b2 = make_block(2, hash(2), hash(99)); + // Should not error, but internally detects reorg. + assert!(detector.record(&b2, &m).await.is_ok()); + + // After the reorg, state should still track the latest block. + let state = detector.state.lock().await; + assert_eq!(state.parent_hash, Some(hash(2))); + } + + #[tokio::test] + async fn sliding_window_capped() { + let detector = ReorgDetectorRecorder::new(); + let m = Metrics::noop(); + + // Insert MAX_BLOCK_HASH_LEN + 100 blocks. + let total = MAX_BLOCK_HASH_LEN + 100; + for i in 0..total { + let num = i as u64; + let parent = + if i == 0 { hash(0) } else { B256::from(alloy_primitives::U256::from(i - 1)) }; + let h = B256::from(alloy_primitives::U256::from(i)); + let block = make_block(num, h, parent); + detector.record(&block, &m).await.unwrap(); + } + + let state = detector.state.lock().await; + assert_eq!(state.block_hashes.len(), MAX_BLOCK_HASH_LEN); + } +} diff --git a/crates/rpc-collector/src/services/collector/l1.rs b/crates/rpc-collector/src/services/collector/l1.rs new file mode 100644 index 0000000..dee8c66 --- /dev/null +++ b/crates/rpc-collector/src/services/collector/l1.rs @@ -0,0 +1,260 @@ +//! L1 collector service — assembles L1-specific metric recorders +//! and wraps the shared [`Collector`](super::Collector) logic. + +use std::{sync::Arc, time::Duration}; + +use alloy_primitives::Address; +use alloy_provider::Provider; +use alloy_sol_types::SolEvent; +use tokio::task::JoinSet; +use tokio_util::sync::CancellationToken; +use tracing::info; + +use crate::{ + bindings, + metrics::{self, Metrics}, + recorders::{ + MetricRecorder, batches_info::BatchesInfoRecorder, + dispute_game_factory::DisputeGameFactoryRecorder, eth_balance::EthBalanceRecorder, + event_counter::EventCounterRecorder, output_oracle::OutputOracleRecorder, + reorg_detector::ReorgDetectorRecorder, + }, + services::{Service, collector::Collector}, + utils::parse_contract_balance_calls, +}; + +/// Configuration needed to build the L1 collector service. +#[derive(Debug, Clone)] +pub struct L1CollectorConfig { + pub poll_interval: Duration, + pub optimism_portal_address: Address, + pub output_oracle_address: Option

, + pub dispute_game_factory_address: Option
, + pub l1_standard_bridge_address: Address, + pub ethereum_proposer_address: Address, + pub ethereum_batcher_address: Address, + pub batch_inbox_address: Address, + pub balance_tracker_address: Option
, + pub contract_balance_calls: Vec, +} + +// ── L1Collector ───────────────────────────────────────────── + +/// The L1 collector service. +/// +/// Wraps the shared [`Collector`] polling loop with the L1-specific +/// set of metric recorders. +pub struct L1Collector

{ + inner: Collector>, +} + +impl

std::fmt::Debug for L1Collector

{ + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("L1Collector").field("inner", &self.inner).finish() + } +} + +impl L1Collector

{ + /// Build a fully-wired L1 collector. + pub fn new(cfg: L1CollectorConfig, provider: Arc

, metrics: Metrics) -> anyhow::Result { + let recorders = Self::build_recorders(&cfg, &provider)?; + Ok(Self { + inner: Collector::new( + provider, + cfg.poll_interval, + recorders, + metrics, + "l1", + metrics::L1_LATEST_BLOCK, + ), + }) + } + + /// Assemble the L1-specific set of metric recorders. + fn build_recorders( + cfg: &L1CollectorConfig, + l1: &Arc

, + ) -> anyhow::Result>> { + let mut rec: Vec> = vec![Arc::new(ReorgDetectorRecorder::new())]; + + // Bridge + portal event counters. + let bridge = cfg.l1_standard_bridge_address; + let portal = cfg.optimism_portal_address; + for (addr, topic, metric) in [ + ( + bridge, + bindings::L1StandardBridge::ETHBridgeInitiated::SIGNATURE_HASH, + metrics::DEPOSIT_INITIATED_COUNT, + ), + ( + bridge, + bindings::L1StandardBridge::ERC20BridgeInitiated::SIGNATURE_HASH, + metrics::DEPOSIT_INITIATED_COUNT, + ), + ( + bridge, + bindings::L1StandardBridge::ETHWithdrawalFinalized::SIGNATURE_HASH, + metrics::WITHDRAWAL_FINALIZED_COUNT, + ), + ( + bridge, + bindings::L1StandardBridge::ERC20WithdrawalFinalized::SIGNATURE_HASH, + metrics::WITHDRAWAL_FINALIZED_COUNT, + ), + ( + portal, + bindings::OptimismPortal::TransactionDeposited::SIGNATURE_HASH, + metrics::DEPOSIT_INITIATED_COUNT, + ), + ( + portal, + bindings::OptimismPortal::WithdrawalFinalized::SIGNATURE_HASH, + metrics::WITHDRAWAL_FINALIZED_COUNT, + ), + ] { + rec.push(Arc::new(EventCounterRecorder::new( + Arc::clone(l1), + addr, + topic, + metric.to_string(), + ))); + } + + // Proposer + batcher balances. + rec.push(Arc::new(EthBalanceRecorder::eth( + Arc::clone(l1), + cfg.ethereum_proposer_address, + metrics::PROPOSER_BALANCE.to_string(), + ))); + rec.push(Arc::new(EthBalanceRecorder::eth( + Arc::clone(l1), + cfg.ethereum_batcher_address, + metrics::BATCH_BALANCE.to_string(), + ))); + + // Batches info. + rec.push(Arc::new(BatchesInfoRecorder::new( + cfg.batch_inbox_address, + cfg.ethereum_batcher_address, + ))); + + // Contract balance calls. + let calls = parse_contract_balance_calls(&cfg.contract_balance_calls)?; + for c in calls { + rec.push(Arc::new(EthBalanceRecorder::contract( + Arc::clone(l1), + c.address, + c.metric, + c.calldata, + ))); + } + + // Output Oracle or Dispute Game Factory. + if let Some(dgf) = cfg.dispute_game_factory_address { + rec.push(Arc::new(DisputeGameFactoryRecorder::new(Arc::clone(l1), dgf))); + } else if let Some(oo) = cfg.output_oracle_address { + rec.push(Arc::new(OutputOracleRecorder::new(Arc::clone(l1), oo))); + } else { + anyhow::bail!( + "either --dispute-game-factory-address or --output-oracle-address must be set" + ); + } + + // Balance tracker (optional). + if let Some(bt) = cfg.balance_tracker_address { + info!("monitoring BalanceTracker events"); + rec.push(Arc::new(EventCounterRecorder::new( + Arc::clone(l1), + bt, + bindings::BalanceTracker::ReceivedFunds::SIGNATURE_HASH, + metrics::RECEIVED_FUNDS_COUNT.to_string(), + ))); + rec.push(Arc::new(EventCounterRecorder::new( + Arc::clone(l1), + bt, + bindings::BalanceTracker::SentProfit::SIGNATURE_HASH, + metrics::SENT_PROFIT_COUNT.to_string(), + ))); + } + + Ok(rec) + } +} + +impl Service for L1Collector

{ + fn name(&self) -> &str { + self.inner.label() + } + + fn spawn(self: Box, set: &mut JoinSet<()>, cancel: CancellationToken) { + self.inner.spawn(set, cancel); + } +} + +#[cfg(test)] +mod tests { + use alloy_primitives::Address; + use alloy_provider::ProviderBuilder; + + use super::*; + use crate::services::Service; + + /// Dummy provider pointing at a non-existent endpoint (never contacted). + fn dummy_provider() -> Arc { + Arc::new( + ProviderBuilder::new() + .disable_recommended_fillers() + .connect_http("http://127.0.0.1:1".parse().unwrap()), + ) + } + + /// Minimal L1 config with both oracle addresses set to `None`. + fn base_cfg() -> L1CollectorConfig { + L1CollectorConfig { + poll_interval: Duration::from_millis(50), + optimism_portal_address: Address::ZERO, + output_oracle_address: None, + dispute_game_factory_address: None, + l1_standard_bridge_address: Address::ZERO, + ethereum_proposer_address: Address::ZERO, + ethereum_batcher_address: Address::ZERO, + batch_inbox_address: Address::ZERO, + balance_tracker_address: None, + contract_balance_calls: vec![], + } + } + + #[test] + fn l1_collector_new_fails_without_oracle_or_dgf() { + let cfg = base_cfg(); // both addresses are None + let result = L1Collector::new(cfg, dummy_provider(), Metrics::noop()); + assert!(result.is_err(), "expected error when both oracle addresses are None"); + } + + #[test] + fn l1_collector_new_with_dgf() { + let mut cfg = base_cfg(); + cfg.dispute_game_factory_address = Some(Address::ZERO); + + let collector = L1Collector::new(cfg, dummy_provider(), Metrics::noop()); + assert!(collector.is_ok(), "expected Ok when dispute_game_factory_address is set"); + } + + #[test] + fn l1_collector_new_with_output_oracle() { + let mut cfg = base_cfg(); + cfg.output_oracle_address = Some(Address::ZERO); + + let collector = L1Collector::new(cfg, dummy_provider(), Metrics::noop()); + assert!(collector.is_ok(), "expected Ok when output_oracle_address is set"); + } + + #[test] + fn l1_collector_name_returns_l1() { + let mut cfg = base_cfg(); + cfg.dispute_game_factory_address = Some(Address::ZERO); + + let collector = L1Collector::new(cfg, dummy_provider(), Metrics::noop()).unwrap(); + assert_eq!(collector.name(), "l1"); + } +} diff --git a/crates/rpc-collector/src/services/collector/l2.rs b/crates/rpc-collector/src/services/collector/l2.rs new file mode 100644 index 0000000..750ce9e --- /dev/null +++ b/crates/rpc-collector/src/services/collector/l2.rs @@ -0,0 +1,190 @@ +//! L2 collector service — assembles L2-specific metric recorders +//! and wraps the shared [`Collector`](super::Collector) logic. + +use std::{sync::Arc, time::Duration}; + +use alloy_primitives::Address; +use alloy_provider::Provider; +use alloy_sol_types::SolEvent; +use tokio::task::JoinSet; +use tokio_util::sync::CancellationToken; +use tracing::info; + +use crate::{ + bindings::{ + self, BASE_FEE_VAULT, L1_FEE_VAULT, L2_STANDARD_BRIDGE, L2_TO_L1_MSG_PASSER, + SEQUENCER_FEE_VAULT, + }, + metrics::{self, Metrics}, + recorders::{ + MetricRecorder, eth_balance::EthBalanceRecorder, event_counter::EventCounterRecorder, + gas_price_oracle::GasPriceOracleRecorder, l2_block_info::L2BlockInfoRecorder, + reorg_detector::ReorgDetectorRecorder, + }, + services::{Service, collector::Collector}, + utils::parse_contract_balance_calls, +}; + +/// Configuration needed to build the L2 collector service. +#[derive(Debug, Clone)] +pub struct L2CollectorConfig { + pub poll_interval: Duration, + pub fee_disburser_address: Option

, + pub l1_system_config_address: Address, + pub contract_balance_calls: Vec, +} + +// ── L2Collector ───────────────────────────────────────────── + +/// The L2 collector service. +/// +/// Wraps the shared [`Collector`] polling loop with the L2-specific +/// set of metric recorders. +pub struct L2Collector

{ + inner: Collector>, +} + +impl

std::fmt::Debug for L2Collector

{ + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("L2Collector").field("inner", &self.inner).finish() + } +} + +impl L2Collector

{ + /// Build a fully-wired L2 collector. + pub fn new(cfg: L2CollectorConfig, provider: Arc

, metrics: Metrics) -> anyhow::Result { + let recorders = Self::build_recorders(&cfg, &provider)?; + Ok(Self { + inner: Collector::new( + provider, + cfg.poll_interval, + recorders, + metrics, + "l2", + metrics::L2_LATEST_BLOCK, + ), + }) + } + + /// Assemble the L2-specific set of metric recorders. + fn build_recorders( + cfg: &L2CollectorConfig, + l2: &Arc

, + ) -> anyhow::Result>> { + let mut rec: Vec> = Vec::new(); + + // Fee Disburser (optional). + if let Some(addr) = cfg.fee_disburser_address { + info!("monitoring FeeDisburser events"); + rec.push(Arc::new(EventCounterRecorder::new( + Arc::clone(l2), + addr, + bindings::FeeDisburser::FeesDisbursed::SIGNATURE_HASH, + metrics::FEES_DISBURSED_COUNT.to_string(), + ))); + } + + rec.push(Arc::new(L2BlockInfoRecorder::new(Arc::clone(l2), cfg.l1_system_config_address))); + rec.push(Arc::new(ReorgDetectorRecorder::new())); + + // Predeploy balances. + for (addr, metric) in [ + (SEQUENCER_FEE_VAULT, metrics::SEQUENCER_BALANCE), + (BASE_FEE_VAULT, metrics::BASE_FEE_BALANCE), + (L1_FEE_VAULT, metrics::L1_FEE_BALANCE), + ] { + rec.push(Arc::new(EthBalanceRecorder::eth(Arc::clone(l2), addr, metric.to_string()))); + } + + // L2 event counters. + for (addr, topic, metric) in [ + ( + L2_TO_L1_MSG_PASSER, + bindings::L2ToL1MessagePasser::MessagePassed::SIGNATURE_HASH, + metrics::WITHDRAWAL_INITIATED_COUNT, + ), + ( + L2_STANDARD_BRIDGE, + bindings::L2StandardBridge::WithdrawalInitiated::SIGNATURE_HASH, + metrics::WITHDRAWAL_INITIATED_COUNT, + ), + ( + L2_STANDARD_BRIDGE, + bindings::L2StandardBridge::DepositFinalized::SIGNATURE_HASH, + metrics::DEPOSIT_FINALIZED_COUNT, + ), + ] { + rec.push(Arc::new(EventCounterRecorder::new( + Arc::clone(l2), + addr, + topic, + metric.to_string(), + ))); + } + + // Gas Price Oracle. + rec.push(Arc::new(GasPriceOracleRecorder::new(Arc::clone(l2)))); + + // Contract balance calls. + let calls = parse_contract_balance_calls(&cfg.contract_balance_calls)?; + for c in calls { + rec.push(Arc::new(EthBalanceRecorder::contract( + Arc::clone(l2), + c.address, + c.metric, + c.calldata, + ))); + } + + Ok(rec) + } +} + +impl Service for L2Collector

{ + fn name(&self) -> &str { + self.inner.label() + } + + fn spawn(self: Box, set: &mut JoinSet<()>, cancel: CancellationToken) { + self.inner.spawn(set, cancel); + } +} + +#[cfg(test)] +mod tests { + use alloy_primitives::Address; + use alloy_provider::ProviderBuilder; + + use super::*; + use crate::services::Service; + + /// Dummy provider pointing at a non-existent endpoint (never contacted). + fn dummy_provider() -> Arc { + Arc::new( + ProviderBuilder::new() + .disable_recommended_fillers() + .connect_http("http://127.0.0.1:1".parse().unwrap()), + ) + } + + fn minimal_cfg() -> L2CollectorConfig { + L2CollectorConfig { + poll_interval: Duration::from_millis(50), + fee_disburser_address: None, + l1_system_config_address: Address::ZERO, + contract_balance_calls: vec![], + } + } + + #[test] + fn l2_collector_new_minimal() { + let collector = L2Collector::new(minimal_cfg(), dummy_provider(), Metrics::noop()); + assert!(collector.is_ok(), "expected Ok with minimal L2 config"); + } + + #[test] + fn l2_collector_name_returns_l2() { + let collector = L2Collector::new(minimal_cfg(), dummy_provider(), Metrics::noop()).unwrap(); + assert_eq!(collector.name(), "l2"); + } +} diff --git a/crates/rpc-collector/src/services/collector/mod.rs b/crates/rpc-collector/src/services/collector/mod.rs new file mode 100644 index 0000000..248a75b --- /dev/null +++ b/crates/rpc-collector/src/services/collector/mod.rs @@ -0,0 +1,349 @@ +//! Core collector: block-polling loop that dispatches blocks to +//! [`MetricRecorder`](crate::recorders::MetricRecorder) implementations. +//! +//! The [`l1`] and [`l2`] submodules provide [`L1Collector`](l1::L1Collector) +//! and [`L2Collector`](l2::L2Collector), which wrap this shared [`Collector`] +//! and implement the [`Service`](crate::services::Service) trait. + +pub mod l1; +pub mod l2; + +use std::{sync::Arc, time::Duration}; + +use alloy_provider::Provider; +use alloy_rpc_types_eth::Block; +use tokio::task::JoinSet; +use tokio_util::sync::CancellationToken; +use tracing::{error, info, warn}; + +use crate::{metrics::Metrics, recorders::MetricRecorder}; + +// ── Collector struct ───────────────────────────────────────── + +/// Shared block-polling collector logic. +/// +/// It fetches blocks sequentially starting from the chain tip and dispatches +/// every block to all registered [`MetricRecorder`]s in parallel. +/// +struct Collector

{ + provider: P, + poll_interval: Duration, + recorders: Vec>, + metrics: Metrics, + label: &'static str, + /// Metric name for the "current block" gauge (e.g. `base.l2.collector.block`). + block_metric: &'static str, +} + +impl

std::fmt::Debug for Collector

{ + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Collector") + .field("poll_interval", &self.poll_interval) + .field("recorders", &self.recorders.len()) + .field("label", &self.label) + .field("block_metric", &self.block_metric) + .finish() + } +} + +impl Collector

{ + fn new( + provider: P, + poll_interval: Duration, + recorders: Vec>, + metrics: Metrics, + label: &'static str, + block_metric: &'static str, + ) -> Self { + Self { provider, poll_interval, recorders, metrics, label, block_metric } + } + + /// Human-readable label for this collector (e.g. `"l1"`, `"l2"`). + const fn label(&self) -> &'static str { + self.label + } + + /// Spawn the collector polling loop as a task on the given [`JoinSet`]. + /// + /// This is the shared spawn implementation used by both + /// [`L1Collector`](l1::L1Collector) and [`L2Collector`](l2::L2Collector). + fn spawn(self, set: &mut JoinSet<()>, cancel: CancellationToken) { + let label = self.label; + set.spawn(async move { + info!(label, "starting metrics collector"); + if let Err(e) = self.run(cancel).await { + error!(label, error = %e, "metrics collector stopped with error"); + } else { + info!(label, "metrics collector stopped gracefully"); + } + }); + } + + /// Run the polling loop until the cancellation token fires. + async fn run(self, cancel: CancellationToken) -> anyhow::Result<()> { + let mut current_block: Option = None; + let mut interval = tokio::time::interval(self.poll_interval); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + loop { + tokio::select! { + _ = cancel.cancelled() => { + info!(label = self.label, "collector shutting down"); + return Ok(()); + } + _ = interval.tick() => { + // If we don't know the start block yet, fetch the latest. + if current_block.is_none() { + match self.provider.get_block_number().await { + Ok(n) => current_block = Some(n), + Err(e) => { + warn!(label = self.label, error = %e, "failed to get latest block number"); + continue; + } + } + } + + // Drain all available blocks. + loop { + let block_num = current_block.unwrap(); + + let block = match self + .provider + .get_block_by_number(block_num.into()) + .full() + .await + { + Ok(Some(b)) => b, + Ok(None) => break, // block not yet available + Err(e) => { + info!( + label = self.label, + block = block_num, + error = %e, + "unable to fetch block" + ); + break; + } + }; + + info!(label = self.label, block = block_num, "fetched block"); + + // Emit the collector-progress gauge. + let _ = self.metrics.gauge(self.block_metric, block_num as f64); + + self.record_all(&block).await; + + current_block = Some(block_num + 1); + } + } + } + } + } + + /// Dispatch all recorders in parallel for a single block. + async fn record_all(&self, block: &Block) { + let mut set = JoinSet::new(); + + for recorder in &self.recorders { + let recorder = Arc::clone(recorder); + let block = block.clone(); + let metrics = self.metrics.clone(); + let label = self.label; + + set.spawn(async move { + if let Err(e) = recorder.record(&block, &metrics).await { + error!( + label, + recorder = recorder.name(), + error = %e, + "metric recorder failed" + ); + } + }); + } + + // Await all. + while set.join_next().await.is_some() {} + } +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering}; + + use alloy_provider::ProviderBuilder; + use async_trait::async_trait; + + use super::*; + + /// A test recorder that counts how many times `record` was called. + #[derive(Debug)] + struct CountingRecorder { + count: AtomicUsize, + } + + impl CountingRecorder { + fn new() -> Self { + Self { count: AtomicUsize::new(0) } + } + fn calls(&self) -> usize { + self.count.load(AtomicOrdering::SeqCst) + } + } + + #[async_trait] + impl MetricRecorder for CountingRecorder { + fn name(&self) -> &'static str { + "counting_recorder" + } + async fn record(&self, _block: &Block, _metrics: &Metrics) -> anyhow::Result<()> { + self.count.fetch_add(1, AtomicOrdering::SeqCst); + Ok(()) + } + } + + #[tokio::test] + async fn collector_cancellation() { + // Create a collector pointing at a non-existent endpoint. + // It will fail to connect, but should still exit cleanly on cancel. + let provider = ProviderBuilder::new() + .disable_recommended_fillers() + .connect_http("http://127.0.0.1:1".parse().unwrap()); + + let collector = Collector::new( + provider, + Duration::from_millis(50), + vec![], + Metrics::noop(), + "test", + "test.block", + ); + + let cancel = CancellationToken::new(); + let cancel_clone = cancel.clone(); + + let handle = tokio::spawn(async move { collector.run(cancel_clone).await }); + + // Give it a moment to start, then cancel. + tokio::time::sleep(Duration::from_millis(100)).await; + cancel.cancel(); + + // Task should complete without panicking. + let result = handle.await; + assert!(result.is_ok(), "collector task panicked"); + assert!(result.unwrap().is_ok(), "collector returned an error"); + } + + #[tokio::test] + async fn collector_processes_blocks() { + use std::sync::atomic::AtomicU64; + + use axum::{Json, Router, routing::post}; + + // A minimal JSON-RPC handler that returns canned responses: + // - eth_blockNumber → 1 + // - eth_getBlockByNumber → a minimal block for block 1, then null + async fn rpc_handler(Json(body): Json) -> Json { + let id = body.get("id").cloned().unwrap_or_else(|| serde_json::json!(1)); + let method = body.get("method").and_then(|m| m.as_str()).unwrap_or(""); + + let result = match method { + "eth_blockNumber" => serde_json::json!("0x1"), + "eth_getBlockByNumber" => { + let count = CALL_COUNT.fetch_add(1, AtomicOrdering::SeqCst); + if count == 0 { + // Return a minimal block for block 1. + serde_json::json!({ + "hash": "0x0000000000000000000000000000000000000000000000000000000000000001", + "parentHash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "number": "0x1", + "timestamp": "0x0", + "gasLimit": "0x0", + "gasUsed": "0x0", + "miner": "0x0000000000000000000000000000000000000000", + "extraData": "0x", + "baseFeePerGas": "0x0", + "stateRoot": "0x0000000000000000000000000000000000000000000000000000000000000000", + "transactionsRoot": "0x0000000000000000000000000000000000000000000000000000000000000000", + "receiptsRoot": "0x0000000000000000000000000000000000000000000000000000000000000000", + "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "difficulty": "0x0", + "nonce": "0x0000000000000000", + "sha3Uncles": "0x0000000000000000000000000000000000000000000000000000000000000000", + "mixHash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "transactions": [], + "size": "0x0", + "totalDifficulty": "0x0", + "uncles": [] + }) + } else { + serde_json::Value::Null + } + } + _ => serde_json::Value::Null, + }; + + Json(serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "result": result + })) + } + + // Use a thread_local-like static for the call counter in the handler. + static CALL_COUNT: AtomicU64 = AtomicU64::new(0); + CALL_COUNT.store(0, AtomicOrdering::SeqCst); + + // Start mock RPC server. + let app = Router::new().route("/", post(rpc_handler)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server_cancel = CancellationToken::new(); + let server_cancel_clone = server_cancel.clone(); + tokio::spawn(async move { + axum::serve(listener, app) + .with_graceful_shutdown(server_cancel_clone.cancelled_owned()) + .await + .unwrap(); + }); + tokio::time::sleep(Duration::from_millis(50)).await; + + // Build a counting recorder. + let recorder = Arc::new(CountingRecorder::new()); + let recorder_ref = Arc::clone(&recorder); + + let provider = ProviderBuilder::new() + .disable_recommended_fillers() + .connect_http(format!("http://{addr}").parse().unwrap()); + + let collector = Collector::new( + provider, + Duration::from_millis(50), + vec![recorder_ref as Arc], + Metrics::noop(), + "test", + "test.block", + ); + + let cancel = CancellationToken::new(); + let cancel_clone = cancel.clone(); + + let handle = tokio::spawn(async move { collector.run(cancel_clone).await }); + + // Wait for the collector to process at least one block. + tokio::time::sleep(Duration::from_millis(500)).await; + cancel.cancel(); + + let result = handle.await; + assert!(result.is_ok(), "collector task panicked"); + + // The recorder should have been called at least once. + assert!( + recorder.calls() >= 1, + "recorder was called {} times, expected >= 1", + recorder.calls() + ); + + server_cancel.cancel(); + } +} diff --git a/crates/rpc-collector/src/services/divergence_checker.rs b/crates/rpc-collector/src/services/divergence_checker.rs new file mode 100644 index 0000000..07cccaa --- /dev/null +++ b/crates/rpc-collector/src/services/divergence_checker.rs @@ -0,0 +1,181 @@ +//! Divergence checker — fetches the same block from Geth and Reth and compares +//! block hash, state root, transaction root and receipt root. + +use std::time::Duration; + +use alloy_provider::Provider; +use tokio::task::JoinSet; +use tokio_util::sync::CancellationToken; +use tracing::{error, info, warn}; + +use crate::{ + metrics::{self, Metrics}, + services::Service, +}; + +// ── Service ────────────────────────────────────────────────── + +/// The divergence checker service, implementing [`Service`]. +#[derive(Debug)] +pub struct DivergenceCheckerService

{ + /// Geth provider. + pub geth: P, + /// Reth provider. + pub reth: P, + /// Interval between divergence check polls. + pub poll_interval: Duration, + /// Grace period for Geth node sync. + pub geth_grace_period: Duration, + /// Metrics client. + pub metrics: Metrics, + /// Human-readable checker name. + pub checker_name: String, +} + +impl Service for DivergenceCheckerService

{ + fn name(&self) -> &str { + &self.checker_name + } + + fn spawn(self: Box, set: &mut JoinSet<()>, cancel: CancellationToken) { + let name = self.checker_name.clone(); + set.spawn(async move { + if let Err(e) = run( + self.geth, + self.reth, + self.poll_interval, + self.geth_grace_period, + self.metrics, + self.checker_name, + cancel, + ) + .await + { + error!(checker = name, error = %e, "divergence checker failed"); + } + }); + } +} + +// ── Core logic ─────────────────────────────────────────────── + +/// Run the divergence checker loop. +async fn run( + geth: P, + reth: P, + poll_interval: Duration, + geth_grace_period: Duration, + metrics: Metrics, + checker_name: String, + cancel: CancellationToken, +) -> anyhow::Result<()> { + let geth_block = geth.get_block_number().await?; + let reth_block = reth.get_block_number().await?; + let mut current_block = geth_block.min(reth_block); + + info!(checker = checker_name, start_block = current_block, "starting divergence checker"); + + let mut interval = tokio::time::interval(poll_interval); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let tags = [("checker", checker_name.as_str())]; + + loop { + tokio::select! { + _ = cancel.cancelled() => return Ok(()), + _ = interval.tick() => { + let geth_latest = match geth.get_block_number().await { + Ok(n) => n, + Err(e) => { + error!(error = %e, "failed to get latest block from Geth"); + metrics.count_with_tags( + metrics::DIVERGENCE_NODE_ERROR, + 1, + &[("checker", checker_name.as_str()), ("node_type", "geth")], + ); + continue; + } + }; + + let reth_latest = match reth.get_block_number().await { + Ok(n) => n, + Err(e) => { + error!(error = %e, "failed to get latest block from Reth"); + metrics.count_with_tags( + metrics::DIVERGENCE_NODE_ERROR, + 1, + &[("checker", checker_name.as_str()), ("node_type", "reth")], + ); + continue; + } + }; + + // Check geth staleness. + if let Ok(Some(header)) = geth.get_header_by_number(geth_latest.into()).await { + let age = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH + Duration::from_secs(header.timestamp)) + .unwrap_or_default(); + if age > geth_grace_period { + warn!(checker = checker_name, geth_block = geth_latest, "geth node appears stale"); + metrics.count_with_tags(metrics::DIVERGENCE_GETH_TIMEOUT, 1, &tags); + } + } + + let next_block = current_block + 1; + if geth_latest.min(reth_latest) < next_block { + continue; + } + + // Fetch block from both nodes in parallel. + let (geth_res, reth_res) = tokio::join!( + geth.get_block_by_number(next_block.into()).full(), + reth.get_block_by_number(next_block.into()).full(), + ); + + let geth_block = match geth_res { + Ok(Some(b)) => b, + _ => { + metrics.count_with_tags( + metrics::DIVERGENCE_NODE_ERROR, + 1, + &[("checker", checker_name.as_str()), ("node_type", "geth")], + ); + continue; + } + }; + + let reth_block = match reth_res { + Ok(Some(b)) => b, + _ => { + metrics.count_with_tags( + metrics::DIVERGENCE_NODE_ERROR, + 1, + &[("checker", checker_name.as_str()), ("node_type", "reth")], + ); + continue; + } + }; + + // Compare the four critical roots. + let matched = geth_block.header.hash == reth_block.header.hash + && geth_block.header.state_root == reth_block.header.state_root + && geth_block.header.transactions_root == reth_block.header.transactions_root + && geth_block.header.receipts_root == reth_block.header.receipts_root; + + if matched { + metrics.count_with_tags(metrics::DIVERGENCE_CROSS_GROUP_DETECTED, 0, &tags); + } else { + error!( + block = next_block, + geth_hash = %geth_block.header.hash, + reth_hash = %reth_block.header.hash, + "divergence detected between geth and reth" + ); + metrics.count_with_tags(metrics::DIVERGENCE_CROSS_GROUP_DETECTED, 1, &tags); + } + + metrics.count_with_tags(metrics::DIVERGENCE_BLOCK_PROCESSED, 1, &tags); + current_block = next_block; + } + } + } +} diff --git a/crates/rpc-collector/src/services/flashblock_validator.rs b/crates/rpc-collector/src/services/flashblock_validator.rs new file mode 100644 index 0000000..85c51c7 --- /dev/null +++ b/crates/rpc-collector/src/services/flashblock_validator.rs @@ -0,0 +1,525 @@ +//! Flashblock validator — WebSocket listener, in-memory cache, +//! and priority-fee ordering validation. +//! +//! Uses the canonical [`base_primitives::flashtypes::Flashblock`] type for +//! message decoding (brotli + JSON) rather than hand-rolled structs. + +use std::{ + collections::{HashMap, HashSet}, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, + time::{Duration, Instant}, +}; + +use alloy_consensus::Transaction as _; +use alloy_primitives::{B256, U256, utils::Unit}; +use alloy_provider::Provider; +use base_primitives::flashblocks::Flashblock; +use futures_util::StreamExt; +use tokio::{sync::RwLock, task::JoinSet}; +use tokio_util::sync::CancellationToken; +use tracing::{error, info, warn}; + +use crate::{ + metrics::{self, Metrics}, + services::Service, + utils::{self, wei_to_unit}, +}; + +// ── Types ──────────────────────────────────────────────────── + +#[derive(Debug, Clone)] +struct FlashblockTransaction { + effective_priority_fee: u128, + is_user: bool, +} + +#[derive(Debug, Clone)] +struct FlashblockData { + flashblock_indices: HashSet, + last_updated: Instant, + transaction_hashes: HashMap, + base_fee: Option, +} + +impl Default for FlashblockData { + fn default() -> Self { + Self { + flashblock_indices: HashSet::new(), + last_updated: Instant::now(), + transaction_hashes: HashMap::new(), + base_fee: None, + } + } +} + +// ── Validator ──────────────────────────────────────────────── + +/// The main flashblock validator, owning the cache and config. +#[derive(Debug)] +pub struct FlashblockValidator

{ + name: String, + ws_url: String, + provider: Arc

, + cache: Arc>>, + current_block: Arc, + poll_interval: Duration, + metrics: Metrics, +} + +impl FlashblockValidator

{ + pub fn new( + name: String, + ws_url: String, + provider: Arc

, + poll_interval: Duration, + metrics: Metrics, + ) -> Self { + Self { + name, + ws_url, + provider, + cache: Arc::new(RwLock::new(HashMap::new())), + current_block: Arc::new(AtomicU64::new(0)), + poll_interval, + metrics, + } + } +} + +// ── Service impl ───────────────────────────────────────────── + +impl Service for FlashblockValidator

{ + fn name(&self) -> &str { + &self.name + } + + fn spawn(self: Box, set: &mut JoinSet<()>, cancel: CancellationToken) { + let name = self.name.clone(); + set.spawn(async move { + if let Err(e) = self.run(cancel).await { + error!(name, error = %e, "flashblock validator failed"); + } + }); + } +} + +// ── Core logic ─────────────────────────────────────────────── + +impl FlashblockValidator

{ + /// Start the flashblock validator: spawns WS listener, L2 poller, and + /// cache cleanup tasks, then awaits cancellation. + async fn run(self, cancel: CancellationToken) -> anyhow::Result<()> { + let current = self.provider.get_block_number().await?; + self.current_block.store(current, Ordering::Relaxed); + + tokio::spawn(listen_stream( + self.ws_url.clone(), + Arc::clone(&self.cache), + self.metrics.clone(), + self.name.clone(), + cancel.clone(), + )); + + tokio::spawn(poll_l2_blocks( + Arc::clone(&self.provider), + Arc::clone(&self.cache), + Arc::clone(&self.current_block), + self.poll_interval, + self.metrics.clone(), + self.name.clone(), + cancel.clone(), + )); + + tokio::spawn(cleanup_loop(Arc::clone(&self.cache), cancel.clone())); + + cancel.cancelled().await; + Ok(()) + } +} + +// ── WebSocket listener ─────────────────────────────────────── + +async fn listen_stream( + ws_url: String, + cache: Arc>>, + metrics: Metrics, + name: String, + cancel: CancellationToken, +) { + loop { + if cancel.is_cancelled() { + return; + } + + match tokio_tungstenite::connect_async(&ws_url).await { + Ok((mut ws, _)) => { + info!(stream = name, "connected to flashblock WebSocket"); + + loop { + tokio::select! { + _ = cancel.cancelled() => return, + msg = ws.next() => { + match msg { + Some(Ok(msg)) if msg.is_binary() || msg.is_text() => { + process_flashblock_message(msg.into_data(), &cache, &metrics, &name).await; + } + Some(Err(e)) => { + error!(stream = name, error = %e, "WS read error"); + break; + } + None => break, + _ => {} + } + } + } + } + } + Err(e) => { + error!(stream = name, error = %e, "failed to connect to flashblock WS"); + } + } + + tokio::time::sleep(Duration::from_secs(5)).await; + } +} + +async fn process_flashblock_message( + message: impl Into>, + cache: &Arc>>, + metrics: &Metrics, + validator_name: &str, +) { + // Decode (brotli + JSON) via the canonical type. + let fb_entry = match Flashblock::try_decode_message(message.into()) { + Ok(fb) => fb, + Err(e) => { + error!(error = %e, "failed to decode flashblock message"); + return; + } + }; + + let block_number = fb_entry.metadata.block_number; + let index = fb_entry.index; + + let mut cache = cache.write().await; + let fb = cache.entry(block_number).or_default(); + + // Store base fee from index 0. + if index == 0 + && let Some(base) = &fb_entry.base + { + fb.base_fee = base.base_fee_per_gas.try_into().ok(); + } + + let base_fee = fb.base_fee; + + fb.flashblock_indices.insert(index); + + // Decode transactions from diff. + let mut flashblock_txs = Vec::new(); + for tx_raw in &fb_entry.diff.transactions { + use alloy_consensus::TxEnvelope; + use alloy_rlp::Decodable; + + let tx = match TxEnvelope::decode(&mut tx_raw.as_ref()) { + Ok(t) => t, + Err(_) => continue, + }; + + fb.transaction_hashes.insert(*tx.tx_hash(), index); + + // Priority fee. + let tip = tx.max_priority_fee_per_gas().unwrap_or(0); + let effective_tip = base_fee.map_or(tip, |bf| { + let max_fee = tx.max_fee_per_gas(); + tip.min(max_fee.saturating_sub(bf)) + }); + + let is_user = tx.tx_type() as u8 != utils::DEPOSIT_TX_TYPE; + flashblock_txs + .push(FlashblockTransaction { effective_priority_fee: effective_tip, is_user }); + } + + fb.last_updated = Instant::now(); + + // Priority fee metrics. + emit_priority_fee_metrics(index, &flashblock_txs, metrics, validator_name); + validate_priority_fee_ordering(block_number, index, &flashblock_txs, metrics, validator_name); +} + +fn emit_priority_fee_metrics( + index: u64, + txs: &[FlashblockTransaction], + metrics: &Metrics, + name: &str, +) { + if index > 10 { + return; + } + + let idx_str = index.to_string(); + let tags = [("validator", name), ("flashblock_index", idx_str.as_str())]; + + let user_txs: Vec<_> = txs.iter().filter(|tx| tx.is_user).collect(); + + for tx in &user_txs { + metrics.histogram_with_tags( + metrics::FLASHBLOCK_PRIORITY_FEE_TIP, + wei_to_unit(U256::from(tx.effective_priority_fee), Unit::GWEI), + &tags, + ); + } + + metrics.histogram_with_tags( + metrics::FLASHBLOCK_PRIORITY_FEE_TX_COUNT, + user_txs.len() as f64, + &tags, + ); + + if let Some(min) = user_txs.iter().map(|tx| tx.effective_priority_fee).min() { + metrics.histogram_with_tags( + metrics::FLASHBLOCK_PRIORITY_FEE_MIN, + wei_to_unit(U256::from(min), Unit::GWEI), + &tags, + ); + } +} + +fn validate_priority_fee_ordering( + block_number: u64, + flashblock_index: u64, + txs: &[FlashblockTransaction], + metrics: &Metrics, + name: &str, +) { + let tags = [("validator", name)]; + + let violations = txs + .windows(2) + .filter(|pair| pair[1].effective_priority_fee > pair[0].effective_priority_fee) + .count(); + + if violations > 0 { + warn!( + block = block_number, + flashblock_index, violations, "priority fee ordering violation" + ); + metrics.count_with_tags(metrics::FLASHBLOCK_TX_ORDER_VIOLATION, 1, &tags); + } else { + metrics.count_with_tags(metrics::FLASHBLOCK_TX_ORDER_SUCCESS, 1, &tags); + } +} + +// ── L2 block poller ────────────────────────────────────────── + +async fn poll_l2_blocks( + provider: Arc

, + cache: Arc>>, + current_block: Arc, + poll_interval: Duration, + metrics: Metrics, + name: String, + cancel: CancellationToken, +) { + let mut interval = tokio::time::interval(poll_interval); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + loop { + tokio::select! { + _ = cancel.cancelled() => return, + _ = interval.tick() => { + let latest = match provider.get_block_number().await { + Ok(n) => n, + Err(e) => { + error!(error = %e, "failed to get L2 block number"); + continue; + } + }; + + let cur = current_block.load(Ordering::Relaxed); + if latest > cur { + for block_num in (cur + 1)..=latest { + let cache_guard = cache.read().await; + if let Some(fb_data) = cache_guard.get(&block_num) { + let fb_data = fb_data.clone(); + drop(cache_guard); + validate_block(&provider, block_num, &fb_data, &metrics, &name).await; + } else { + drop(cache_guard); + warn!(block = block_num, "no flashblocks received for block"); + metrics.count_with_tags( + metrics::FLASHBLOCK_NO_DATA_RECEIVED, + 1, + &[("validator", name.as_str())], + ); + } + } + current_block.store(latest, Ordering::Relaxed); + } + } + } + } +} + +async fn validate_block( + provider: &P, + block_number: u64, + cache: &FlashblockData, + metrics: &Metrics, + name: &str, +) { + let block = match provider.get_block_by_number(block_number.into()).full().await { + Ok(Some(b)) => b, + Ok(None) | Err(_) => return, + }; + + let l2_tx_hashes: HashSet = { + use alloy_network::TransactionResponse as _; + block.transactions.txns().map(|tx| tx.tx_hash()).collect() + }; + + let total_flashblocks = cache.flashblock_indices.len(); + let tags = [("validator", name)]; + + metrics.gauge_with_tags(metrics::FLASHBLOCK_TOTAL_RECEIVED, total_flashblocks as f64, &tags); + + // Find missing indices (0..=10). + let missing: Vec = (0..=10).filter(|i| !cache.flashblock_indices.contains(i)).collect(); + if !missing.is_empty() { + metrics.count_with_tags(metrics::FLASHBLOCK_MISSING_INDICES, missing.len() as i64, &tags); + } + + // Compare tx sets. + let mut reorged_indices = HashSet::new(); + let mut reorged_txs = 0usize; + + for (tx_hash, &fb_index) in &cache.transaction_hashes { + if !l2_tx_hashes.contains(tx_hash) { + reorged_txs += 1; + reorged_indices.insert(fb_index); + } + } + + let total_txs = cache.transaction_hashes.len(); + let included_txs = total_txs.saturating_sub(reorged_txs); + + metrics.count_with_tags(metrics::FLASHBLOCK_TX_TOTAL, total_txs as i64, &tags); + metrics.count_with_tags(metrics::FLASHBLOCK_TX_REORGED, reorged_txs as i64, &tags); + metrics.count_with_tags(metrics::FLASHBLOCK_TX_INCLUDED, included_txs as i64, &tags); + + let successful_flashblocks = total_flashblocks.saturating_sub(reorged_indices.len()); + metrics.count_with_tags(metrics::FLASHBLOCK_SUCCESS, successful_flashblocks as i64, &tags); + metrics.count_with_tags(metrics::FLASHBLOCK_REORG, reorged_indices.len() as i64, &tags); + + if reorged_txs > 0 { + metrics.count_with_tags(metrics::FLASHBLOCK_HASH_MISMATCH, 1, &tags); + } else { + metrics.count_with_tags(metrics::FLASHBLOCK_VALIDATION_SUCCESS, 1, &tags); + } +} + +// ── Cache cleanup ──────────────────────────────────────────── + +async fn cleanup_loop(cache: Arc>>, cancel: CancellationToken) { + let mut interval = tokio::time::interval(Duration::from_secs(60)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + loop { + tokio::select! { + _ = cancel.cancelled() => return, + _ = interval.tick() => { + // Clean cache entries older than 20 s. + let mut c = cache.write().await; + let now = Instant::now(); + c.retain(|_, fb| now.duration_since(fb.last_updated) < Duration::from_secs(20)); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn user_tx(fee: u128) -> FlashblockTransaction { + FlashblockTransaction { effective_priority_fee: fee, is_user: true } + } + + fn system_tx(fee: u128) -> FlashblockTransaction { + FlashblockTransaction { effective_priority_fee: fee, is_user: false } + } + + // ── validate_priority_fee_ordering ─────────────────────── + + #[test] + fn ordering_valid_no_violations() { + let m = Metrics::noop(); + // Descending priority fees → no violations + let txs = vec![user_tx(100), user_tx(80), user_tx(50), user_tx(10)]; + // Should not panic; will emit FLASHBLOCK_TX_ORDER_SUCCESS + validate_priority_fee_ordering(1, 0, &txs, &m, "test"); + } + + #[test] + fn ordering_violation_detected() { + let m = Metrics::noop(); + // A tx with higher fee after a lower-fee tx → violation + let txs = vec![user_tx(100), user_tx(50), user_tx(80)]; // 80 > 50 → violation + validate_priority_fee_ordering(1, 0, &txs, &m, "test"); + } + + #[test] + fn ordering_empty_txs() { + let m = Metrics::noop(); + validate_priority_fee_ordering(1, 0, &[], &m, "test"); + } + + #[test] + fn ordering_single_tx() { + let m = Metrics::noop(); + validate_priority_fee_ordering(1, 0, &[user_tx(42)], &m, "test"); + } + + // ── emit_priority_fee_metrics ──────────────────────────── + + #[test] + fn emit_priority_fee_skips_high_index() { + let m = Metrics::noop(); + let txs = vec![user_tx(100)]; + // Index > 10 → should return early and emit nothing (no panic) + emit_priority_fee_metrics(11, &txs, &m, "test"); + emit_priority_fee_metrics(100, &txs, &m, "test"); + } + + #[test] + fn emit_priority_fee_filters_system_txs() { + let m = Metrics::noop(); + // Mix of user and system txs: only user txs should contribute to fee metrics + let txs = vec![ + user_tx(100), + system_tx(0), // Deposit tx — should be filtered + user_tx(50), + ]; + // Index within range → should emit metrics for user txs only (no panic) + emit_priority_fee_metrics(0, &txs, &m, "test"); + } + + #[test] + fn emit_priority_fee_boundary_index() { + let m = Metrics::noop(); + let txs = vec![user_tx(42)]; + // Index exactly 10 → should emit + emit_priority_fee_metrics(10, &txs, &m, "test"); + } + + #[test] + fn emit_priority_fee_no_user_txs() { + let m = Metrics::noop(); + let txs = vec![system_tx(0), system_tx(0)]; + // All system txs → should not panic, just emit 0 count + emit_priority_fee_metrics(0, &txs, &m, "test"); + } +} diff --git a/crates/rpc-collector/src/services/health_checker.rs b/crates/rpc-collector/src/services/health_checker.rs new file mode 100644 index 0000000..20bf1c0 --- /dev/null +++ b/crates/rpc-collector/src/services/health_checker.rs @@ -0,0 +1,315 @@ +//! Node health checking — unified for both L1 and L2. +//! +//! Each node is polled in parallel: fetch latest block number, +//! check staleness against a grace period, detect rate-limiting. +//! For L2, an optional sequencer node provides a "delta" metric. + +use std::time::Duration; + +use alloy_provider::{Provider, ProviderBuilder, RootProvider}; +use op_alloy_network::Optimism; +use tokio::task::JoinSet; +use tokio_util::sync::CancellationToken; +use tracing::{error, info, warn}; + +use crate::{ + metrics::{self, Metrics}, + services::Service, +}; + +// ── Node type ──────────────────────────────────────────────── + +/// Represents a node used in health checks. +#[derive(Debug, Clone)] +pub struct Node { + pub client: RootProvider, + pub url: String, + pub provider: String, +} + +impl Node { + /// Create a [`Node`] from a URL. Returns `None` if the URL cannot be parsed. + pub fn new(url: &str, provider_name: &str) -> Option { + let parsed: url::Url = url.parse().ok()?; + let client = ProviderBuilder::new() + .disable_recommended_fillers() + .network::() + .connect_http(parsed); + Some(Self { client, url: url.to_string(), provider: provider_name.to_string() }) + } +} + +/// Build a list of [`Node`]s, logging and skipping failures. +pub fn build_node_list(urls: &[String], provider_name: &str) -> Vec { + urls.iter() + .filter_map(|url| { + Node::new(url, provider_name).or_else(|| { + warn!(url, "failed to parse node URL, skipping"); + None + }) + }) + .collect() +} + +/// Build a node list for external nodes, sanitizing URLs to just the host. +pub fn build_node_list_sanitized(urls: &[String], provider_name: &str) -> Vec { + urls.iter() + .filter_map(|raw_url| { + let parsed = raw_url.parse::().ok().or_else(|| { + warn!(url = raw_url.as_str(), "invalid external node URL, skipping"); + None + })?; + let sanitized = parsed.host_str().unwrap_or(raw_url).to_string(); + let client = ProviderBuilder::new() + .disable_recommended_fillers() + .network::() + .connect_http(parsed); + Some(Node { client, url: sanitized, provider: provider_name.to_string() }) + }) + .collect() +} + +// ── Service ────────────────────────────────────────────────── + +/// Node health checking service (L1 or L2), implementing [`Service`]. +#[derive(Debug)] +pub struct HealthChecker { + /// Nodes to check. + pub nodes: Vec, + /// Optional sequencer node (L2 only). + pub sequencer: Option, + /// Interval between health check polls. + pub poll_interval: Duration, + /// Grace period before a node is considered stale. + pub grace_period: Duration, + /// Metrics client. + pub metrics: Metrics, + /// Layer label: `"l1"` or `"l2"`. + pub layer: &'static str, +} + +impl Service for HealthChecker { + fn name(&self) -> &str { + self.layer + } + + fn spawn(self: Box, set: &mut JoinSet<()>, cancel: CancellationToken) { + if self.nodes.is_empty() { + return; + } + let layer = self.layer; + set.spawn(async move { + info!(layer, "starting node health checks"); + if let Err(e) = run( + self.nodes, + self.sequencer, + self.poll_interval, + self.grace_period, + self.metrics, + layer, + cancel, + ) + .await + { + error!(layer, error = %e, "health checker stopped with error"); + } + }); + } +} + +// ── Core logic ─────────────────────────────────────────────── + +/// Run health checks for a set of nodes at the given interval. +/// +/// Works for both L1 and L2. Pass `sequencer: Some(node)` for L2 to +/// enable sequencer-delta tracking. +async fn run( + nodes: Vec, + sequencer: Option, + poll_interval: Duration, + grace_period: Duration, + metrics: Metrics, + layer: &str, + cancel: CancellationToken, +) -> anyhow::Result<()> { + let mut interval = tokio::time::interval(poll_interval); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + loop { + tokio::select! { + _ = cancel.cancelled() => return Ok(()), + _ = interval.tick() => { + let seq_block = fetch_sequencer_block(sequencer.as_ref(), &metrics).await; + check_nodes(&nodes, seq_block, grace_period, &metrics, layer).await; + } + } + } +} + +/// Fetch the sequencer's latest block (if configured). Returns `None` if +/// there is no sequencer or the fetch fails. +async fn fetch_sequencer_block(sequencer: Option<&Node>, metrics: &Metrics) -> Option { + let seq = sequencer?; + let tags = [("url", seq.url.as_str()), ("layer", "l2")]; + match tokio::time::timeout(Duration::from_secs(5), seq.client.get_block_number()).await { + Ok(Ok(n)) => { + metrics.gauge_with_tags(metrics::SEQUENCER_LATEST_BLOCK, n as f64, &tags); + info!(url = seq.url, block = n, "fetched sequencer block"); + Some(n) + } + _ => { + metrics.count_with_tags(metrics::NODE_ERROR, 1, &tags); + None + } + } +} + +/// Check all nodes in parallel. +async fn check_nodes( + nodes: &[Node], + sequencer_block: Option, + grace_period: Duration, + metrics: &Metrics, + layer: &str, +) { + let mut set = JoinSet::new(); + + for node in nodes { + let url = node.url.clone(); + let provider = node.provider.clone(); + let client = node.client.clone(); + let metrics = metrics.clone(); + let layer = layer.to_string(); + + set.spawn(async move { + let base_tags: [(&str, &str); 2] = [("url", url.as_str()), ("layer", layer.as_str())]; + + match tokio::time::timeout(Duration::from_secs(5), client.get_block_number()).await { + Err(_) => { + error!(url, "timeout fetching latest block"); + metrics.count_with_tags(metrics::NODE_ERROR, 1, &base_tags); + } + Ok(Err(e)) => { + let err_str = e.to_string(); + if err_str.contains("429") { + metrics.count_with_tags(metrics::NODE_RATE_LIMITED, 1, &base_tags); + } else { + metrics.count_with_tags(metrics::NODE_ERROR, 1, &base_tags); + } + error!(url, error = %e, "unable to fetch latest block"); + } + Ok(Ok(block_num)) => { + let prov_tags: [(&str, &str); 3] = [ + ("url", url.as_str()), + ("layer", layer.as_str()), + ("provider", provider.as_str()), + ]; + metrics.gauge_with_tags( + metrics::NODE_LATEST_BLOCK, + block_num as f64, + &prov_tags, + ); + info!(url, provider, block = block_num, "fetched latest block"); + + if let Some(seq_block) = sequencer_block { + let delta = seq_block.saturating_sub(block_num); + metrics.gauge_with_tags(metrics::SEQUENCER_DELTA, delta as f64, &base_tags); + } + + // Staleness check via header timestamp. + if let Ok(Some(header)) = client.get_header_by_number(block_num.into()).await { + let block_time = + std::time::UNIX_EPOCH + Duration::from_secs(header.timestamp); + let age = std::time::SystemTime::now() + .duration_since(block_time) + .unwrap_or_default(); + + metrics.gauge_with_tags( + metrics::NODE_LATEST_TIME, + header.timestamp as f64, + &base_tags, + ); + + if age > grace_period { + metrics.count_with_tags(metrics::NODE_STALL, 1, &base_tags); + } else { + metrics.gauge_with_tags( + metrics::NODE_HEALTHY, + block_num as f64, + &base_tags, + ); + } + } + } + } + }); + } + + while set.join_next().await.is_some() {} +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── build_node_list ────────────────────────────────────── + + #[test] + fn build_node_list_valid_urls() { + let urls = + vec!["http://localhost:8545".to_string(), "http://geth.example.com:8545".to_string()]; + let nodes = build_node_list(&urls, "test_provider"); + assert_eq!(nodes.len(), 2); + assert_eq!(nodes[0].provider, "test_provider"); + assert_eq!(nodes[0].url, "http://localhost:8545"); + assert_eq!(nodes[1].url, "http://geth.example.com:8545"); + } + + #[test] + fn build_node_list_skips_invalid() { + let urls = vec![ + "http://valid.host:8545".to_string(), + "not a valid url at all!@#$%".to_string(), + "http://another-valid.host:8545".to_string(), + ]; + let nodes = build_node_list(&urls, "test"); + // The invalid URL should be silently skipped. + assert_eq!(nodes.len(), 2); + } + + #[test] + fn build_node_list_empty() { + let urls: Vec = vec![]; + let nodes = build_node_list(&urls, "test"); + assert!(nodes.is_empty()); + } + + // ── build_node_list_sanitized ──────────────────────────── + + #[test] + fn build_node_list_sanitized_strips_path() { + let urls = vec!["http://host.example.com/path?key=val".to_string()]; + let nodes = build_node_list_sanitized(&urls, "ext"); + assert_eq!(nodes.len(), 1); + // The URL should be sanitized to just the host. + assert_eq!(nodes[0].url, "host.example.com"); + assert_eq!(nodes[0].provider, "ext"); + } + + #[test] + fn build_node_list_sanitized_skips_invalid() { + let urls = vec!["http://valid.host:8545".to_string(), "garbage://???".to_string()]; + let nodes = build_node_list_sanitized(&urls, "ext"); + // The second URL doesn't produce a valid host but may still parse. + // Only URLs that can be parsed at all are included. + assert!(!nodes.is_empty()); + } + + #[test] + fn build_node_list_sanitized_preserves_host_only() { + let urls = vec!["http://192.168.1.100:8545/v1/mainnet".to_string()]; + let nodes = build_node_list_sanitized(&urls, "infra"); + assert_eq!(nodes.len(), 1); + assert_eq!(nodes[0].url, "192.168.1.100"); + } +} diff --git a/crates/rpc-collector/src/services/health_server.rs b/crates/rpc-collector/src/services/health_server.rs new file mode 100644 index 0000000..ef186c1 --- /dev/null +++ b/crates/rpc-collector/src/services/health_server.rs @@ -0,0 +1,118 @@ +//! Health check HTTP server. +//! +//! Exposes a `/_health` endpoint that returns 200 OK. + +use std::net::SocketAddr; + +use axum::{Router, routing::get}; +use tokio::task::JoinSet; +use tokio_util::sync::CancellationToken; +use tracing::{error, info}; + +use crate::services::Service; + +async fn health() -> &'static str { + "OK" +} + +/// Bind and serve the health check server. +/// +/// The server shuts down gracefully when the `cancel` token is triggered. +async fn serve(addr: SocketAddr, cancel: CancellationToken) -> anyhow::Result<()> { + let app = Router::new().route("/_health", get(health)); + + let listener = tokio::net::TcpListener::bind(addr).await?; + info!("health server listening on {addr}"); + + axum::serve(listener, app).with_graceful_shutdown(cancel.cancelled_owned()).await?; + + Ok(()) +} + +/// The health HTTP server, implementing [`Service`]. +#[derive(Debug)] +pub struct HealthServer { + /// Address to bind the health check server. + pub addr: SocketAddr, +} + +impl Service for HealthServer { + fn name(&self) -> &str { + "health-server" + } + + fn spawn(self: Box, set: &mut JoinSet<()>, cancel: CancellationToken) { + let addr = self.addr; + set.spawn(async move { + if let Err(e) = serve(addr, cancel).await { + error!(error = %e, "health server failed"); + } + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Helper: start the health server on an OS-assigned port, returning the + /// bound address and a cancel token. + async fn start_server() -> (SocketAddr, CancellationToken) { + let cancel = CancellationToken::new(); + // Bind to port 0 to get an OS-assigned port. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + let cancel_clone = cancel.clone(); + tokio::spawn(async move { + let app = Router::new().route("/_health", get(health)); + axum::serve(listener, app) + .with_graceful_shutdown(cancel_clone.cancelled_owned()) + .await + .unwrap(); + }); + + // Give the server a moment to start. + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + + (addr, cancel) + } + + #[tokio::test] + async fn health_endpoint_returns_ok() { + let (addr, cancel) = start_server().await; + + let url = format!("http://{addr}/_health"); + let resp = reqwest::get(&url).await.unwrap(); + + assert_eq!(resp.status(), 200); + let body = resp.text().await.unwrap(); + assert_eq!(body, "OK"); + + cancel.cancel(); + } + + #[tokio::test] + async fn health_server_shuts_down() { + let (addr, cancel) = start_server().await; + + // Verify it's running. + let url = format!("http://{addr}/_health"); + assert!(reqwest::get(&url).await.is_ok()); + + // Cancel and give it time to shut down. + cancel.cancel(); + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + + // After shutdown, connections should fail. + let result = reqwest::Client::builder() + .timeout(std::time::Duration::from_millis(200)) + .build() + .unwrap() + .get(&url) + .send() + .await; + + assert!(result.is_err()); + } +} diff --git a/crates/rpc-collector/src/services/mempool_listener.rs b/crates/rpc-collector/src/services/mempool_listener.rs new file mode 100644 index 0000000..9b781b1 --- /dev/null +++ b/crates/rpc-collector/src/services/mempool_listener.rs @@ -0,0 +1,577 @@ +//! Mempool listener — polls `txpool_content` from Geth and Reth nodes, +//! reclassifies underpriced Geth transactions, and compares snapshots. + +use std::{collections::HashMap, time::Duration}; + +use alloy_provider::Provider; +use serde::Deserialize; +use tokio::task::JoinSet; +use tokio_util::sync::CancellationToken; +use tracing::{error, info}; + +use crate::{ + metrics::{self, Metrics}, + services::Service, +}; + +// ── Types ──────────────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +struct MempoolTransaction { + #[serde(rename = "gasPrice", default)] + gas_price: String, + #[serde(rename = "maxFeePerGas", default)] + max_fee_per_gas: String, + #[serde(rename = "type", default)] + tx_type: String, +} + +/// `txpool_content` response shape: `{ pending: { addr: { nonce: tx } }, queued: ... }`. +type TxpoolContent = HashMap>>; +/// Pool category: `"pending"` or `"queued"`. +type TxPool = HashMap>; + +/// Count total transactions across all addresses in a pool. +fn count_pool_txs(pool: Option<&TxPool>) -> i64 { + pool.map_or(0, |p| p.values().map(|txs| txs.len() as i64).sum()) +} + +#[derive(Debug, Default)] +struct MempoolSnapshot { + pending_count: i64, + queued_count: i64, + reclassified_count: i64, + original_pending_count: i64, +} + +// ── Service ────────────────────────────────────────────────── + +/// The mempool listener service, implementing [`Service`]. +#[derive(Debug)] +pub struct MempoolListenerService

{ + /// Geth provider for `txpool_content`. + pub geth: P, + /// Reth provider for `txpool_content`. + pub reth: P, + /// Interval between mempool polls. + pub poll_interval: Duration, + /// Metrics client. + pub metrics: Metrics, + /// Human-readable listener name. + pub listener_name: String, +} + +impl Service for MempoolListenerService

{ + fn name(&self) -> &str { + &self.listener_name + } + + fn spawn(self: Box, set: &mut JoinSet<()>, cancel: CancellationToken) { + let name = self.listener_name.clone(); + set.spawn(async move { + if let Err(e) = run( + self.geth, + self.reth, + self.poll_interval, + self.metrics, + self.listener_name, + cancel, + ) + .await + { + error!(listener = name, error = %e, "mempool listener failed"); + } + }); + } +} + +// ── Core logic ─────────────────────────────────────────────── + +/// Run the mempool listener loop. Both Geth and Reth providers are required. +async fn run( + geth: P, + reth: P, + poll_interval: Duration, + metrics: Metrics, + listener_name: String, + cancel: CancellationToken, +) -> anyhow::Result<()> { + info!(listener = listener_name, "starting mempool listener"); + + let mut interval = tokio::time::interval(poll_interval); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + loop { + tokio::select! { + _ = cancel.cancelled() => { + info!(listener = listener_name, "mempool listener shutting down"); + return Ok(()); + } + _ = interval.tick() => { + collect_and_compare(&geth, &reth, &metrics, &listener_name).await; + } + } + } +} + +async fn collect_and_compare( + geth: &P, + reth: &P, + metrics: &Metrics, + listener_name: &str, +) { + // Fetch base fee from Geth (needed for reclassification). + let base_fee: Option = match geth + .get_block_by_number(alloy_eips::BlockNumberOrTag::Latest) + .await + { + Ok(Some(block)) => block.header.base_fee_per_gas.map(|b| b as u128), + _ => { + metrics.count_with_tags( + metrics::MEMPOOL_ERROR, + 1, + &[("listener", listener_name), ("client", "geth"), ("error", "base_fee_failed")], + ); + None + } + }; + + let geth_snap = collect_geth_snapshot(geth, base_fee, metrics, listener_name).await; + let reth_snap = collect_reth_snapshot(reth, metrics, listener_name).await; + + if let Some(ref snap) = geth_snap { + report_snapshot(snap, "geth", metrics, listener_name); + } + if let Some(ref snap) = reth_snap { + report_snapshot(snap, "reth", metrics, listener_name); + } + + if let (Some(g), Some(r)) = (&geth_snap, &reth_snap) { + compare_snapshots(g, r, metrics, listener_name); + } +} + +async fn collect_geth_snapshot( + provider: &P, + base_fee: Option, + metrics: &Metrics, + listener: &str, +) -> Option { + let content: TxpoolContent = match provider.client().request_noparams("txpool_content").await { + Ok(c) => c, + Err(e) => { + error!(error = %e, "failed to get Geth txpool_content"); + metrics.count_with_tags( + metrics::MEMPOOL_ERROR, + 1, + &[("listener", listener), ("client", "geth"), ("error", "snapshot_failed")], + ); + return None; + } + }; + + let mut snap = MempoolSnapshot::default(); + + if let Some(pending) = content.get("pending") { + for txs_by_nonce in pending.values() { + let addr_count = txs_by_nonce.len() as i64; + snap.original_pending_count += addr_count; + + if let Some(bf) = base_fee { + let result = reclassify_for_address(txs_by_nonce, bf); + snap.pending_count += result.0; + snap.reclassified_count += result.1; + } else { + snap.pending_count += addr_count; + } + } + } + + snap.queued_count = count_pool_txs(content.get("queued")) + snap.reclassified_count; + Some(snap) +} + +async fn collect_reth_snapshot( + provider: &P, + metrics: &Metrics, + listener: &str, +) -> Option { + let content: TxpoolContent = match provider.client().request_noparams("txpool_content").await { + Ok(c) => c, + Err(e) => { + error!(error = %e, "failed to get Reth txpool_content"); + metrics.count_with_tags( + metrics::MEMPOOL_ERROR, + 1, + &[("listener", listener), ("client", "reth"), ("error", "snapshot_failed")], + ); + return None; + } + }; + + Some(MempoolSnapshot { + pending_count: count_pool_txs(content.get("pending")), + queued_count: count_pool_txs(content.get("queued")), + ..Default::default() + }) +} + +/// Geth reclassification: returns `(remaining_pending, reclassified)`. +fn reclassify_for_address( + txs_by_nonce: &HashMap, + base_fee: u128, +) -> (i64, i64) { + let total = txs_by_nonce.len() as i64; + if total == 0 { + return (0, 0); + } + + // Sort by nonce. + let mut nonces: Vec<(u64, &MempoolTransaction)> = txs_by_nonce + .iter() + .filter_map(|(nonce_str, tx)| { + let nonce_str = nonce_str.strip_prefix("0x").unwrap_or(nonce_str); + u64::from_str_radix(nonce_str, 16).ok().map(|n| (n, tx)) + }) + .collect(); + nonces.sort_by_key(|(n, _)| *n); + + if nonces.is_empty() { + return (total, 0); + } + + // Find first underpriced tx — everything from there on is reclassified. + nonces.iter().position(|(_, tx)| is_underpriced(tx, base_fee)).map_or((total, 0), |idx| { + let pending = idx as i64; + (pending, nonces.len() as i64 - pending) + }) +} + +fn is_underpriced(tx: &MempoolTransaction, base_fee: u128) -> bool { + let gas_price_str = if tx.tx_type == "0x2" && !tx.max_fee_per_gas.is_empty() { + &tx.max_fee_per_gas + } else { + &tx.gas_price + }; + + if gas_price_str.is_empty() { + return false; + } + + let hex = gas_price_str.strip_prefix("0x").unwrap_or(gas_price_str); + u128::from_str_radix(hex, 16).is_ok_and(|price| price < base_fee) +} + +fn report_snapshot(snap: &MempoolSnapshot, client: &str, metrics: &Metrics, listener: &str) { + let tags = [("listener", listener), ("client", client)]; + metrics.gauge_with_tags(metrics::MEMPOOL_PENDING_COUNT, snap.pending_count as f64, &tags); + metrics.gauge_with_tags(metrics::MEMPOOL_QUEUED_COUNT, snap.queued_count as f64, &tags); + + if client == "geth" { + metrics.gauge_with_tags( + metrics::MEMPOOL_RECLASSIFIED_COUNT, + snap.reclassified_count as f64, + &tags, + ); + metrics.gauge_with_tags( + metrics::MEMPOOL_ORIGINAL_PENDING_COUNT, + snap.original_pending_count as f64, + &tags, + ); + } + + metrics.count_with_tags(metrics::MEMPOOL_COLLECTION_SUCCESS, 1, &tags); +} + +fn compare_snapshots( + geth: &MempoolSnapshot, + reth: &MempoolSnapshot, + metrics: &Metrics, + listener: &str, +) { + let pending_diff = geth.pending_count - reth.pending_count; + let queued_diff = geth.queued_count - reth.queued_count; + let total_geth = geth.pending_count + geth.queued_count; + let total_reth = reth.pending_count + reth.queued_count; + let total_diff = total_geth - total_reth; + + let tags = [("listener", listener)]; + metrics.gauge_with_tags(metrics::MEMPOOL_PENDING_DIFF, pending_diff as f64, &tags); + metrics.gauge_with_tags(metrics::MEMPOOL_QUEUED_DIFF, queued_diff as f64, &tags); + metrics.gauge_with_tags(metrics::MEMPOOL_TOTAL_DIFF, total_diff as f64, &tags); + + info!( + listener, + geth_pending = geth.pending_count, + reth_pending = reth.pending_count, + pending_diff, + geth_queued = geth.queued_count, + reth_queued = reth.queued_count, + queued_diff, + total_diff, + "mempool comparison" + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── count_pool_txs ─────────────────────────────────────── + + #[test] + fn count_pool_txs_none() { + assert_eq!(count_pool_txs(None), 0); + } + + #[test] + fn count_pool_txs_empty_map() { + let pool: TxPool = HashMap::new(); + assert_eq!(count_pool_txs(Some(&pool)), 0); + } + + #[test] + fn count_pool_txs_counts_all() { + let mut pool: TxPool = HashMap::new(); + // Address "0xAA" with 2 txs + let mut aa_txs = HashMap::new(); + aa_txs.insert( + "0x0".to_string(), + MempoolTransaction { + gas_price: "0x3b9aca00".to_string(), + max_fee_per_gas: String::new(), + tx_type: "0x0".to_string(), + }, + ); + aa_txs.insert( + "0x1".to_string(), + MempoolTransaction { + gas_price: "0x3b9aca00".to_string(), + max_fee_per_gas: String::new(), + tx_type: "0x0".to_string(), + }, + ); + pool.insert("0xAA".to_string(), aa_txs); + // Address "0xBB" with 1 tx + let mut bb_txs = HashMap::new(); + bb_txs.insert( + "0x0".to_string(), + MempoolTransaction { + gas_price: "0x3b9aca00".to_string(), + max_fee_per_gas: String::new(), + tx_type: "0x0".to_string(), + }, + ); + pool.insert("0xBB".to_string(), bb_txs); + + assert_eq!(count_pool_txs(Some(&pool)), 3); + } + + // ── is_underpriced ─────────────────────────────────────── + + #[test] + fn is_underpriced_type2_below_base() { + // EIP-1559 tx with max_fee < base_fee → underpriced + let tx = MempoolTransaction { + gas_price: String::new(), + max_fee_per_gas: "0x5".to_string(), // 5 wei + tx_type: "0x2".to_string(), + }; + assert!(is_underpriced(&tx, 10)); // base_fee = 10 + } + + #[test] + fn is_underpriced_type2_above_base() { + let tx = MempoolTransaction { + gas_price: String::new(), + max_fee_per_gas: "0x14".to_string(), // 20 wei + tx_type: "0x2".to_string(), + }; + assert!(!is_underpriced(&tx, 10)); + } + + #[test] + fn is_underpriced_legacy_above_base() { + let tx = MempoolTransaction { + gas_price: "0x3b9aca00".to_string(), // 1 Gwei + max_fee_per_gas: String::new(), + tx_type: "0x0".to_string(), + }; + assert!(!is_underpriced(&tx, 1_000)); // base_fee = 1000 wei + } + + #[test] + fn is_underpriced_legacy_below_base() { + let tx = MempoolTransaction { + gas_price: "0x1".to_string(), // 1 wei + max_fee_per_gas: String::new(), + tx_type: "0x0".to_string(), + }; + assert!(is_underpriced(&tx, 1_000)); // base_fee = 1000 wei + } + + #[test] + fn is_underpriced_empty_gas_price() { + let tx = MempoolTransaction { + gas_price: String::new(), + max_fee_per_gas: String::new(), + tx_type: "0x0".to_string(), + }; + // Empty gas price → not underpriced (can't parse) + assert!(!is_underpriced(&tx, 10)); + } + + // ── reclassify_for_address ─────────────────────────────── + + #[test] + fn reclassify_all_priced_ok() { + let mut txs: HashMap = HashMap::new(); + // Two txs both above base_fee + txs.insert( + "0x0".to_string(), + MempoolTransaction { + gas_price: "0x64".to_string(), // 100 wei + max_fee_per_gas: String::new(), + tx_type: "0x0".to_string(), + }, + ); + txs.insert( + "0x1".to_string(), + MempoolTransaction { + gas_price: "0xc8".to_string(), // 200 wei + max_fee_per_gas: String::new(), + tx_type: "0x0".to_string(), + }, + ); + + let (pending, reclassified) = reclassify_for_address(&txs, 50); // base_fee = 50 + assert_eq!(pending, 2); + assert_eq!(reclassified, 0); + } + + #[test] + fn reclassify_first_underpriced_cascades() { + let mut txs: HashMap = HashMap::new(); + // Nonce 0: underpriced (1 wei < 50 base fee) + txs.insert( + "0x0".to_string(), + MempoolTransaction { + gas_price: "0x1".to_string(), + max_fee_per_gas: String::new(), + tx_type: "0x0".to_string(), + }, + ); + // Nonce 1: properly priced but after an underpriced one → still reclassified + txs.insert( + "0x1".to_string(), + MempoolTransaction { + gas_price: "0x64".to_string(), + max_fee_per_gas: String::new(), + tx_type: "0x0".to_string(), + }, + ); + // Nonce 2: also properly priced + txs.insert( + "0x2".to_string(), + MempoolTransaction { + gas_price: "0xc8".to_string(), + max_fee_per_gas: String::new(), + tx_type: "0x0".to_string(), + }, + ); + + let (pending, reclassified) = reclassify_for_address(&txs, 50); + // First tx (nonce 0) is underpriced → all 3 reclassified, 0 pending + assert_eq!(pending, 0); + assert_eq!(reclassified, 3); + } + + #[test] + fn reclassify_middle_underpriced_cascades() { + let mut txs: HashMap = HashMap::new(); + // Nonce 0: properly priced + txs.insert( + "0x0".to_string(), + MempoolTransaction { + gas_price: "0x64".to_string(), + max_fee_per_gas: String::new(), + tx_type: "0x0".to_string(), + }, + ); + // Nonce 1: underpriced + txs.insert( + "0x1".to_string(), + MempoolTransaction { + gas_price: "0x1".to_string(), + max_fee_per_gas: String::new(), + tx_type: "0x0".to_string(), + }, + ); + // Nonce 2: properly priced but after an underpriced one + txs.insert( + "0x2".to_string(), + MempoolTransaction { + gas_price: "0x64".to_string(), + max_fee_per_gas: String::new(), + tx_type: "0x0".to_string(), + }, + ); + + let (pending, reclassified) = reclassify_for_address(&txs, 50); + assert_eq!(pending, 1); // Only nonce 0 is pending + assert_eq!(reclassified, 2); // Nonce 1 and 2 reclassified + } + + #[test] + fn reclassify_empty_map() { + let txs: HashMap = HashMap::new(); + let (pending, reclassified) = reclassify_for_address(&txs, 50); + assert_eq!(pending, 0); + assert_eq!(reclassified, 0); + } + + // ── compare_snapshots ──────────────────────────────────── + + #[test] + fn compare_snapshots_diffs() { + let m = Metrics::noop(); + let geth = MempoolSnapshot { + pending_count: 100, + queued_count: 50, + reclassified_count: 10, + original_pending_count: 110, + }; + let reth = MempoolSnapshot { pending_count: 80, queued_count: 30, ..Default::default() }; + + // This should not panic. The diffs are: + // pending_diff = 100 - 80 = 20 + // queued_diff = 50 - 30 = 20 + // total_diff = (100+50) - (80+30) = 40 + compare_snapshots(&geth, &reth, &m, "test_listener"); + } + + #[test] + fn compare_snapshots_negative_diff() { + let m = Metrics::noop(); + let geth = MempoolSnapshot { pending_count: 10, queued_count: 5, ..Default::default() }; + let reth = MempoolSnapshot { pending_count: 50, queued_count: 30, ..Default::default() }; + + // Negative diffs should not panic. + compare_snapshots(&geth, &reth, &m, "test_listener"); + } + + // ── report_snapshot ────────────────────────────────────── + + #[test] + fn report_snapshot_does_not_panic() { + let m = Metrics::noop(); + let snap = MempoolSnapshot { + pending_count: 42, + queued_count: 13, + reclassified_count: 5, + original_pending_count: 47, + }; + report_snapshot(&snap, "geth", &m, "test_listener"); + report_snapshot(&snap, "reth", &m, "test_listener"); + } +} diff --git a/crates/rpc-collector/src/services/mod.rs b/crates/rpc-collector/src/services/mod.rs new file mode 100644 index 0000000..6a3427b --- /dev/null +++ b/crates/rpc-collector/src/services/mod.rs @@ -0,0 +1,39 @@ +//! Service trait and implementations for all rpc-collector services. +//! +//! Every long-running component of rpc-collector implements [`Service`], +//! enabling the binary to build them via a factory and spawn them uniformly. + +pub mod collector; +mod divergence_checker; +mod flashblock_validator; +mod health_checker; +mod health_server; +mod mempool_listener; +mod snapshots; + +use tokio::task::JoinSet; +use tokio_util::sync::CancellationToken; + +/// A self-contained service that can be spawned onto a [`JoinSet`]. +/// +/// Every long-running component of rpc-collector implements this trait, +/// enabling the binary to build them via a factory and spawn them uniformly. +pub trait Service: Send { + /// Human-readable name, used in logs. + fn name(&self) -> &str; + + /// Spawn this service as one or more tasks on the given [`JoinSet`]. + fn spawn(self: Box, set: &mut JoinSet<()>, cancel: CancellationToken); +} + +// Re-export all public types from service modules. +pub use collector::{ + l1::{L1Collector, L1CollectorConfig}, + l2::{L2Collector, L2CollectorConfig}, +}; +pub use divergence_checker::DivergenceCheckerService; +pub use flashblock_validator::FlashblockValidator; +pub use health_checker::{HealthChecker, Node, build_node_list, build_node_list_sanitized}; +pub use health_server::HealthServer; +pub use mempool_listener::MempoolListenerService; +pub use snapshots::SnapshotsService; diff --git a/crates/rpc-collector/src/services/snapshots.rs b/crates/rpc-collector/src/services/snapshots.rs new file mode 100644 index 0000000..e4b184b --- /dev/null +++ b/crates/rpc-collector/src/services/snapshots.rs @@ -0,0 +1,211 @@ +//! Snapshots collector — periodically polls S3 bucket endpoints to check +//! snapshot freshness, age, and size. + +use std::time::Duration; + +use tokio::task::JoinSet; +use tokio_util::sync::CancellationToken; +use tracing::{error, info}; + +use super::Service; +use crate::metrics::{self, Metrics}; + +// ── Service ────────────────────────────────────────────────── + +/// Snapshot monitoring service, implementing [`Service`]. +#[derive(Debug)] +pub struct SnapshotsService { + /// S3 bucket URLs to monitor. + pub buckets: Vec, + /// Interval between snapshot checks. + pub poll_interval: Duration, + /// Metrics client. + pub metrics: Metrics, +} + +impl Service for SnapshotsService { + fn name(&self) -> &str { + "snapshots" + } + + fn spawn(self: Box, set: &mut JoinSet<()>, cancel: CancellationToken) { + if self.buckets.is_empty() { + info!("no snapshot buckets configured, skipping snapshots collector"); + return; + } + set.spawn(async move { + info!("starting snapshots collector"); + if let Err(e) = run(self.buckets, self.poll_interval, self.metrics, cancel).await { + error!(error = %e, "snapshots collector failed"); + } + }); + } +} + +// ── Core logic ─────────────────────────────────────────────── + +/// Run the snapshots collector loop. +async fn run( + buckets: Vec, + poll_interval: Duration, + metrics: Metrics, + cancel: CancellationToken, +) -> anyhow::Result<()> { + let http_client = reqwest::Client::builder().timeout(Duration::from_secs(30)).build()?; + + // Record once immediately, then enter the polling loop. + record_metrics_in_parallel(&http_client, &buckets, &metrics).await; + + let mut interval = tokio::time::interval(poll_interval); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + // Skip the first tick since we already recorded above. + interval.tick().await; + + loop { + tokio::select! { + _ = cancel.cancelled() => return Ok(()), + _ = interval.tick() => { + record_metrics_in_parallel(&http_client, &buckets, &metrics).await; + } + } + } +} + +/// Fetch snapshot data for every bucket in parallel. +async fn record_metrics_in_parallel( + http_client: &reqwest::Client, + buckets: &[String], + metrics: &Metrics, +) { + let mut set = tokio::task::JoinSet::new(); + + for bucket in buckets { + let client = http_client.clone(); + let m = metrics.clone(); + let bucket_url = bucket.clone(); + + set.spawn(async move { + check_bucket(&client, &bucket_url, &m).await; + }); + } + + while set.join_next().await.is_some() {} +} + +/// Extract a 10-digit Unix timestamp from a snapshot filename. +/// +/// Looks for a 10-digit number immediately before `.tar.gz` or `.tar.zst`. +/// e.g. `snapshot-1234567890.tar.zst` → `Some(1234567890)`. +fn parse_snapshot_timestamp(filename: &str) -> Option { + let stem = filename.strip_suffix(".tar.gz").or_else(|| filename.strip_suffix(".tar.zst"))?; + // The timestamp is the last 10 chars of the stem (or follows a separator). + let digits: String = stem.chars().rev().take_while(|c| c.is_ascii_digit()).collect(); + let digits: String = digits.chars().rev().collect(); + if digits.len() == 10 { digits.parse().ok() } else { None } +} + +/// Check a single S3 bucket endpoint for its latest snapshot. +async fn check_bucket(http_client: &reqwest::Client, bucket_url: &str, metrics: &Metrics) { + let tags: [(&str, &str); 1] = [("bucket", bucket_url)]; + + info!(bucket = bucket_url, "fetching snapshot information"); + + // 1. Fetch the "latest" file to get the snapshot filename. + let latest_url = format!("{bucket_url}/latest"); + let res = match http_client.get(&latest_url).send().await { + Ok(r) => r, + Err(e) => { + error!(error = %e, bucket = bucket_url, "error fetching snapshot latest"); + metrics.incr_with_tags(metrics::SNAPSHOT_ERROR, &tags); + return; + } + }; + + if !res.status().is_success() { + error!( + bucket = bucket_url, + status = %res.status(), + "non-200 response fetching snapshot latest" + ); + metrics.incr_with_tags(metrics::SNAPSHOT_ERROR, &tags); + return; + } + + let body = match res.text().await { + Ok(b) => b, + Err(e) => { + error!(error = %e, bucket = bucket_url, "error reading latest file body"); + metrics.incr_with_tags(metrics::SNAPSHOT_ERROR, &tags); + return; + } + }; + + let file_name = body.trim(); + + // 2. Parse the 10-digit Unix timestamp from the filename. + let timestamp = match parse_snapshot_timestamp(file_name) { + Some(t) => t, + None => { + error!(bucket = bucket_url, file = file_name, "invalid snapshot filename"); + metrics.incr_with_tags(metrics::SNAPSHOT_ERROR, &tags); + return; + } + }; + + // 3. HEAD request to get the file size. + let file_url = format!("{bucket_url}/{file_name}"); + let head_res = match http_client.head(&file_url).send().await { + Ok(r) => r, + Err(e) => { + error!(error = %e, bucket = bucket_url, "error fetching snapshot HEAD"); + metrics.incr_with_tags(metrics::SNAPSHOT_ERROR, &tags); + return; + } + }; + + if !head_res.status().is_success() { + error!( + bucket = bucket_url, + status = %head_res.status(), + "non-200 response for snapshot HEAD" + ); + metrics.incr_with_tags(metrics::SNAPSHOT_ERROR, &tags); + return; + } + + let content_length = head_res.content_length().unwrap_or(0); + + // 4. Compute age and emit metrics. + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let age_secs = now.saturating_sub(timestamp) as f64; + + metrics.gauge_with_tags(metrics::SNAPSHOT_SUCCESS, timestamp as f64, &tags); + metrics.gauge_with_tags(metrics::SNAPSHOT_AGE, age_secs, &tags); + metrics.gauge_with_tags(metrics::SNAPSHOT_SIZE, content_length as f64, &tags); + + info!( + bucket = bucket_url, + file = file_name, + timestamp = timestamp, + size = content_length, + "fetched snapshot information" + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_snapshot_timestamp() { + assert_eq!(parse_snapshot_timestamp("snapshot-1234567890.tar.zst"), Some(1234567890)); + assert_eq!(parse_snapshot_timestamp("snapshot-1234567890.tar.gz"), Some(1234567890)); + assert_eq!(parse_snapshot_timestamp("1234567890.tar.zst"), Some(1234567890)); + assert_eq!(parse_snapshot_timestamp("bad.tar.zst"), None); + assert_eq!(parse_snapshot_timestamp("snapshot-123.tar.zst"), None); // too short + assert_eq!(parse_snapshot_timestamp("snapshot.txt"), None); + } +} diff --git a/crates/rpc-collector/src/utils.rs b/crates/rpc-collector/src/utils.rs new file mode 100644 index 0000000..1c40501 --- /dev/null +++ b/crates/rpc-collector/src/utils.rs @@ -0,0 +1,119 @@ +//! Utility helpers for the RPC Collector. + +use alloy_primitives::{Address, U256, utils::Unit}; + +/// OP Stack deposit (system) transaction type (`0x7E`). +/// +/// Transactions with this type are injected by the sequencer and should +/// generally be excluded from user-facing metrics (priority fees, L2 fees, etc.). +pub const DEPOSIT_TX_TYPE: u8 = 126; + +/// Convert a wei amount to the given [`Unit`] as an `f64`. +/// +/// Uses direct numeric division — no string formatting round-trip. +/// For very large values this may lose precision, but it is sufficient +/// for metric reporting. +pub fn wei_to_unit(wei: U256, unit: Unit) -> f64 { + let divisor = 10_f64.powi(unit.get() as i32); + u128::try_from(wei).unwrap_or(u128::MAX) as f64 / divisor +} + +/// Parsed contract-balance call triple (`metric|address|calldata`). +#[derive(Debug, Clone)] +pub struct ContractBalanceCall { + pub metric: String, + pub address: Address, + pub calldata: Vec, +} + +/// Parse `metric|address|calldata` triples from a string slice. +pub fn parse_contract_balance_calls(raw: &[String]) -> anyhow::Result> { + raw.iter() + .map(|v| { + let parts: Vec<&str> = v.split('|').collect(); + if parts.len() != 3 { + anyhow::bail!("invalid contract balance call flag: {v}"); + } + Ok(ContractBalanceCall { + metric: parts[0].to_string(), + address: parts[1].parse()?, + calldata: alloy_primitives::hex::decode(parts[2])?, + }) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── wei_to_unit ────────────────────────────────────────── + + #[test] + fn wei_to_unit_one_ether() { + let one_eth = U256::from(1_000_000_000_000_000_000u128); + let result = wei_to_unit(one_eth, Unit::ETHER); + assert!((result - 1.0).abs() < 1e-12, "expected ~1.0, got {result}"); + } + + #[test] + fn wei_to_unit_one_gwei() { + let result = wei_to_unit(U256::from(1_000_000_000u128), Unit::GWEI); + assert!((result - 1.0).abs() < 1e-12, "expected ~1.0, got {result}"); + } + + #[test] + fn wei_to_unit_gwei_large() { + // 100 Gwei = 100e9 wei + let result = wei_to_unit(U256::from(100_000_000_000u128), Unit::GWEI); + assert!((result - 100.0).abs() < 1e-9, "expected ~100.0, got {result}"); + } + + // ── parse_contract_balance_calls ───────────────────────── + + #[test] + fn parse_contract_balance_calls_valid() { + let input = + vec!["my.metric|0x0000000000000000000000000000000000000001|deadbeef".to_string()]; + let result = parse_contract_balance_calls(&input).unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].metric, "my.metric"); + assert_eq!( + result[0].address, + "0x0000000000000000000000000000000000000001".parse::

().unwrap() + ); + assert_eq!(result[0].calldata, vec![0xde, 0xad, 0xbe, 0xef]); + } + + #[test] + fn parse_contract_balance_calls_invalid_parts() { + // Only two parts instead of three + let input = vec!["my.metric|0x0000000000000000000000000000000000000001".to_string()]; + assert!(parse_contract_balance_calls(&input).is_err()); + } + + #[test] + fn parse_contract_balance_calls_invalid_address() { + let input = vec!["my.metric|not_an_address|deadbeef".to_string()]; + assert!(parse_contract_balance_calls(&input).is_err()); + } + + #[test] + fn parse_contract_balance_calls_empty() { + let input: Vec = vec![]; + let result = parse_contract_balance_calls(&input).unwrap(); + assert!(result.is_empty()); + } + + #[test] + fn parse_contract_balance_calls_multiple() { + let input = vec![ + "metric.a|0x0000000000000000000000000000000000000001|aa".to_string(), + "metric.b|0x0000000000000000000000000000000000000002|bb".to_string(), + ]; + let result = parse_contract_balance_calls(&input).unwrap(); + assert_eq!(result.len(), 2); + assert_eq!(result[0].metric, "metric.a"); + assert_eq!(result[1].metric, "metric.b"); + } +}