diff --git a/.env.example b/.env.example index 7932d14..34decaa 100644 --- a/.env.example +++ b/.env.example @@ -13,3 +13,16 @@ BNB_HTTP_URL=https://bsc-rpc.publicnode.com # When set, the CLI will sign, simulate via eth_call, and only then # enqueue — see `CLAUDE.md` safety invariants. #CHARON_SIGNER_KEY=0x... + +# Private-RPC endpoint used by the submitter to bypass the public +# mempool. MUST be https:// or wss:// — plaintext schemes are +# rejected at connect time. bloxroute and blocknative both issue +# URLs of the form `https://./?auth=`; +# either format works. Leave unset on testnet/dev runs that set +# `allow_public_mempool = true` in config/default.toml. +CHARON_BSC_PRIVATE_RPC_URL= + +# Optional bearer token attached as `Authorization: Bearer ` +# to every private-RPC request. Leave empty if the vendor embeds the +# API key in the URL instead of using a header. +CHARON_BSC_PRIVATE_RPC_AUTH= diff --git a/Cargo.lock b/Cargo.lock index e21ba4f..370f9c8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -583,7 +583,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "538a04a37221469cac0ce231b737fd174de2fdfcdd843bdd068cb39ed3e066ad" dependencies = [ "alloy-json-rpc", - "base64", + "base64 0.22.1", "futures-util", "futures-utils-wasm", "serde", @@ -639,7 +639,7 @@ dependencies = [ "alloy-pubsub", "alloy-transport", "futures", - "http", + "http 1.4.0", "rustls", "serde_json", "tokio", @@ -918,6 +918,189 @@ dependencies = [ "serde", ] +[[package]] +name = "ascii-canvas" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8824ecca2e851cec16968d54a01dd372ef8f95b244fb84b84e70128be347c3c6" +dependencies = [ + "term", +] + +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "async-attributes" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3203e79f4dd9bdda415ed03cf14dae5a2bf775c683a00f94e9cd1faf0f596e5" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[package]] +name = "async-channel" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" +dependencies = [ + "concurrent-queue", + "event-listener 2.5.3", + "futures-core", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-global-executor" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c" +dependencies = [ + "async-channel 2.5.0", + "async-executor", + "async-io", + "async-lock", + "blocking", + "futures-lite", + "once_cell", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener 5.4.1", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-object-pool" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "333c456b97c3f2d50604e8b2624253b7f787208cb72eb75e64b0ad11b221652c" +dependencies = [ + "async-std", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel 2.5.0", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener 5.4.1", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-std" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c8e079a4ab67ae52b7403632e4618815d6db36d2a010cfe41b02c1b1578f93b" +dependencies = [ + "async-attributes", + "async-channel 1.9.0", + "async-global-executor", + "async-io", + "async-lock", + "async-process", + "crossbeam-utils", + "futures-channel", + "futures-core", + "futures-io", + "futures-lite", + "gloo-timers", + "kv-log-macro", + "log", + "memchr", + "once_cell", + "pin-project-lite", + "pin-utils", + "slab", + "wasm-bindgen-futures", +] + [[package]] name = "async-stream" version = "0.3.6" @@ -940,6 +1123,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.89" @@ -991,6 +1180,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" @@ -1003,21 +1198,47 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "basic-cookies" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67bd8fd42c16bdb08688243dc5f0cc117a3ca9efeeaba3a345a18a6159ad96f7" +dependencies = [ + "lalrpop", + "lalrpop-util", + "regex", +] + [[package]] name = "bimap" version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "230c5f1ca6a325a32553f8640d31ac9b49f2411e901e427570154868b46da4f7" +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec 0.6.3", +] + [[package]] name = "bit-set" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "bit-vec", + "bit-vec 0.8.0", ] +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + [[package]] name = "bit-vec" version = "0.8.0" @@ -1051,6 +1272,19 @@ dependencies = [ "generic-array", ] +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel 2.5.0", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "blst" version = "0.3.16" @@ -1107,9 +1341,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.60" +version = "1.2.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" dependencies = [ "find-msvc-tools", "shlex", @@ -1121,6 +1355,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "charon-cli" version = "0.1.0" @@ -1165,9 +1405,13 @@ dependencies = [ "anyhow", "charon-core", "dotenvy", + "httpmock", + "reqwest", + "secrecy", "thiserror 2.0.18", "tokio", "tracing", + "url", ] [[package]] @@ -1261,6 +1505,15 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "const-hex" version = "1.18.1" @@ -1384,9 +1637,9 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.10.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" [[package]] name = "der" @@ -1474,6 +1727,27 @@ dependencies = [ "subtle", ] +[[package]] +name = "dirs-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -1554,6 +1828,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "ena" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabffdaee24bd1bf95c5ef7cec31260444317e72ea56c4c91750e8b7ee58d5f1" +dependencies = [ + "log", +] + [[package]] name = "enum-ordinalize" version = "4.3.2" @@ -1590,6 +1873,33 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener 5.4.1", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -1646,6 +1956,12 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + [[package]] name = "fnv" version = "1.0.7" @@ -1736,6 +2052,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.32" @@ -1800,8 +2129,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1811,9 +2142,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -1835,6 +2168,18 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "group" version = "0.13.0" @@ -1903,6 +2248,17 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http" version = "1.4.0" @@ -1913,6 +2269,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + [[package]] name = "http-body" version = "1.0.1" @@ -1920,7 +2287,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.4.0", ] [[package]] @@ -1931,8 +2298,8 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "pin-project-lite", ] @@ -1942,6 +2309,63 @@ 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 = "httpmock" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08ec9586ee0910472dec1a1f0f8acf52f0fdde93aea74d70d4a3107b4be0fd5b" +dependencies = [ + "assert-json-diff", + "async-object-pool", + "async-std", + "async-trait", + "base64 0.21.7", + "basic-cookies", + "crossbeam-utils", + "form_urlencoded", + "futures-util", + "hyper 0.14.32", + "lazy_static", + "levenshtein", + "log", + "regex", + "serde", + "serde_json", + "serde_regex", + "similar", + "tokio", + "url", +] + +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + [[package]] name = "hyper" version = "1.9.0" @@ -1952,8 +2376,8 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "httparse", "itoa", "pin-project-lite", @@ -1962,6 +2386,22 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http 1.4.0", + "hyper 1.9.0", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.7", +] + [[package]] name = "hyper-tls" version = "0.6.0" @@ -1970,7 +2410,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ "bytes", "http-body-util", - "hyper", + "hyper 1.9.0", "hyper-util", "native-tls", "tokio", @@ -1984,18 +2424,18 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", - "http", - "http-body", - "hyper", + "http 1.4.0", + "http-body 1.0.1", + "hyper 1.9.0", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.3", "tokio", "tower-service", "tracing", @@ -2187,6 +2627,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.13.0" @@ -2256,10 +2705,50 @@ dependencies = [ ] [[package]] -name = "konst_macro_rules" -version = "0.2.19" +name = "konst_macro_rules" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" + +[[package]] +name = "kv-log-macro" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f" +dependencies = [ + "log", +] + +[[package]] +name = "lalrpop" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55cb077ad656299f160924eb2912aa147d7339ea7d69e1b5517326fdcec3c1ca" +dependencies = [ + "ascii-canvas", + "bit-set 0.5.3", + "ena", + "itertools 0.11.0", + "lalrpop-util", + "petgraph", + "pico-args", + "regex", + "regex-syntax", + "string_cache", + "term", + "tiny-keccak", + "unicode-xid", + "walkdir", +] + +[[package]] +name = "lalrpop-util" +version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" +checksum = "507460a910eb7b32ee961886ff48539633b788a36b65692b95f225b844c82553" +dependencies = [ + "regex-automata", +] [[package]] name = "lazy_static" @@ -2273,6 +2762,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +[[package]] +name = "levenshtein" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db13adb97ab515a3691f56e4dbab09283d0b86cb45abd991d8634a9d6f501760" + [[package]] name = "libc" version = "0.2.186" @@ -2285,6 +2780,15 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "libc", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -2311,6 +2815,9 @@ name = "log" version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +dependencies = [ + "value-bag", +] [[package]] name = "lru" @@ -2321,6 +2828,12 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "macro-string" version = "0.1.4" @@ -2385,6 +2898,12 @@ dependencies = [ "tempfile", ] +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -2551,6 +3070,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2596,6 +3121,16 @@ dependencies = [ "ucd-trie", ] +[[package]] +name = "petgraph" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +dependencies = [ + "fixedbitset", + "indexmap", +] + [[package]] name = "pharos" version = "0.5.3" @@ -2606,6 +3141,21 @@ dependencies = [ "rustc_version 0.4.1", ] +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + [[package]] name = "pin-project" version = "1.1.11" @@ -2638,6 +3188,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkcs8" version = "0.10.2" @@ -2654,6 +3215,20 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -2678,6 +3253,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + [[package]] name = "prettyplease" version = "0.2.37" @@ -2745,8 +3326,8 @@ version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ - "bit-set", - "bit-vec", + "bit-set 0.8.0", + "bit-vec 0.8.0", "bitflags", "num-traits", "rand 0.9.4", @@ -2764,6 +3345,61 @@ version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2 0.6.3", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.3", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.45" @@ -2875,6 +3511,29 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + [[package]] name = "regex-automata" version = "0.4.14" @@ -2898,13 +3557,14 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-core", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", - "hyper", + "hyper 1.9.0", + "hyper-rustls", "hyper-tls", "hyper-util", "js-sys", @@ -2912,6 +3572,8 @@ dependencies = [ "native-tls", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", "rustls-pki-types", "serde", "serde_json", @@ -2919,6 +3581,7 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-native-tls", + "tokio-rustls", "tower", "tower-http", "tower-service", @@ -2926,6 +3589,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "webpki-roots 1.0.7", ] [[package]] @@ -3059,6 +3723,7 @@ version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ + "web-time", "zeroize", ] @@ -3097,6 +3762,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.29" @@ -3243,6 +3917,16 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_regex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8136f1a4ea815d7eac4101cfd0b16dc0cb5e1fe1b8609dfd728058656b7badf" +dependencies = [ + "regex", + "serde", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -3341,6 +4025,18 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + [[package]] name = "slab" version = "0.4.12" @@ -3356,6 +4052,16 @@ dependencies = [ "serde", ] +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "socket2" version = "0.6.3" @@ -3388,6 +4094,18 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + [[package]] name = "strsim" version = "0.11.1" @@ -3516,6 +4234,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "term" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59df8ac95d96ff9bede18eb7300b0fda5e5d8d90960e76f8e14ae765eedbf1f" +dependencies = [ + "dirs-next", + "rustversion", + "winapi", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -3593,6 +4322,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +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.52.1" @@ -3605,7 +4349,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.3", "tokio-macros", "windows-sys 0.61.2", ] @@ -3777,8 +4521,8 @@ dependencies = [ "bitflags", "bytes", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "iri-string", "pin-project-lite", "tower", @@ -3874,7 +4618,7 @@ dependencies = [ "byteorder", "bytes", "data-encoding", - "http", + "http 1.4.0", "httparse", "log", "rand 0.8.6", @@ -3975,6 +4719,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "value-bag" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0" + [[package]] name = "vcpkg" version = "0.2.15" @@ -3996,6 +4746,16 @@ dependencies = [ "libc", ] +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -4142,6 +4902,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -4166,6 +4936,37 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" @@ -4178,7 +4979,16 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", ] [[package]] @@ -4196,14 +5006,31 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] [[package]] @@ -4212,48 +5039,96 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "0.7.15" diff --git a/Cargo.toml b/Cargo.toml index 804be67..55440fc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,9 +35,17 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } anyhow = "1" thiserror = "2" -# Secret-holding wrapper (zeroes memory on drop, redacts from Debug) +# Secret-holding wrapper (zeroes memory on drop, redacts from Debug). +# `serde` feature lets `ChainConfig::private_rpc_*` deserialize directly +# into `SecretString` via the TOML env-substitution layer. secrecy = { version = "0.10", features = ["serde"] } +# URL parsing (scheme validation in the submitter) +url = "2" + +# HTTP client (custom headers on private-RPC transport) +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } + # Async trait objects async-trait = "0.1" diff --git a/config/default.toml b/config/default.toml index c3aded2..4e41e9f 100644 --- a/config/default.toml +++ b/config/default.toml @@ -33,6 +33,19 @@ ws_url = "${BNB_WS_URL}" http_url = "${BNB_HTTP_URL}" # Validator-friendly tip on BSC; raise during congestion. priority_fee_gwei = 1 +# Private-RPC endpoint (bloxroute / blocknative). Required for any +# chain that has a deployed liquidator — Config::validate refuses to +# start otherwise. Leave the env var unset only on testnet / dev +# runs where `allow_public_mempool = true` is also set below. +private_rpc_url = "${CHARON_BSC_PRIVATE_RPC_URL}" +# Optional bearer token attached as `Authorization: Bearer ` +# to every private-RPC request. Use when the vendor prefers a header +# over a URL-embedded API key. Unset = no Authorization header. +private_rpc_auth = "${CHARON_BSC_PRIVATE_RPC_AUTH}" +# Escape hatch for local anvil / testnet runs with no private RPC. +# NEVER enable on mainnet — submitting liquidation calldata to the +# public mempool is a guaranteed front-run. +allow_public_mempool = false # ── Lending protocols ───────────────────────────────────────────────────── [protocol.venus] diff --git a/crates/charon-cli/src/main.rs b/crates/charon-cli/src/main.rs index 80db867..26f401b 100644 --- a/crates/charon-cli/src/main.rs +++ b/crates/charon-cli/src/main.rs @@ -210,6 +210,9 @@ async fn main() -> Result<()> { let config = Config::load(&cli.config) .with_context(|| format!("failed to load config from {}", cli.config.display()))?; + config + .validate() + .context("config validation failed — refusing to start")?; // SECURITY: only counts and non-secret scalars here. Never log // ws_url, http_url, signer_key, or the full Debug of Config — diff --git a/crates/charon-core/src/config.rs b/crates/charon-core/src/config.rs index 08301c4..a5e4ff5 100644 --- a/crates/charon-core/src/config.rs +++ b/crates/charon-core/src/config.rs @@ -39,6 +39,17 @@ pub enum ConfigError { Parse(#[from] toml::de::Error), #[error("validation: {0}")] Validation(String), + /// A chain has no `private_rpc_url` and did not opt in to + /// public-mempool submission via `allow_public_mempool = true`. + /// Refusing to start is deliberate: broadcasting liquidation + /// calldata to the public mempool is a guaranteed front-run. + #[error( + "chain '{chain}' has no private_rpc_url; set one, or set allow_public_mempool = true to opt in (testnet/dev only)" + )] + PrivateRpcRequired { + /// Chain key (matches a `[chain.]` section). + chain: String, + }, } /// Shorthand `Result`. @@ -201,6 +212,37 @@ pub struct ChainConfig { pub http_url: String, #[serde(default = "default_priority_fee_gwei")] pub priority_fee_gwei: u64, + /// Optional private-RPC endpoint for transaction submission + /// (bloxroute / blocknative on BSC, sequencer endpoints on L2s). + /// When set, the submitter posts `eth_sendRawTransaction` here + /// instead of the public `http_url`, so pending txs skip the + /// public mempool and front-runners. + /// + /// Held in a [`SecretString`] because vendor URLs typically embed + /// an API key in the path (e.g. `https://.../?auth=`). Call + /// `expose_secret()` only at the single point of use (the + /// submitter); never log the raw string. + /// + /// An empty env-substituted string is treated as `None`, so + /// `CHARON__PRIVATE_RPC_URL=` in `.env` produces an unset + /// endpoint (caught by validation unless `allow_public_mempool` + /// is set) rather than a nonsense empty-URL submitter. + #[serde(default, deserialize_with = "deser_optional_secret")] + pub private_rpc_url: Option, + /// Optional bearer token for the private RPC. Attached verbatim + /// as `Authorization: Bearer `. Use this when the vendor + /// prefers a header over a URL-embedded key. Loaded from + /// `CHARON__PRIVATE_RPC_AUTH` via env substitution. Empty + /// string = unset. + #[serde(default, deserialize_with = "deser_optional_secret")] + pub private_rpc_auth: Option, + /// Escape hatch for local / testnet runs where no private RPC + /// exists. When `false` (the default) [`Config::validate`] + /// refuses to start a chain with no `private_rpc_url`, because + /// submitting liquidation calldata to the public mempool is a + /// guaranteed front-run. NEVER enable on mainnet. + #[serde(default)] + pub allow_public_mempool: bool, } fn default_priority_fee_gwei() -> u64 { @@ -214,6 +256,23 @@ impl fmt::Debug for ChainConfig { .field("ws_url", &"") .field("http_url", &"") .field("priority_fee_gwei", &self.priority_fee_gwei) + .field( + "private_rpc_url", + &if self.private_rpc_url.is_some() { + "" + } else { + "" + }, + ) + .field( + "private_rpc_auth", + &if self.private_rpc_auth.is_some() { + "" + } else { + "" + }, + ) + .field("allow_public_mempool", &self.allow_public_mempool) .finish() } } @@ -278,11 +337,30 @@ impl Config { } /// Cross-reference chain keys, reject sentinel zero addresses, and - /// sanity-check scanner bucket thresholds + cadence. - fn validate(&self) -> Result<()> { + /// sanity-check scanner bucket thresholds + cadence. Also enforces + /// the private-mempool gate: every chain must either carry a + /// `private_rpc_url` or opt in to `allow_public_mempool`. + /// + /// Called from `from_str` on every load, and additionally exposed + /// for callers (CLI) that want an explicit belt-and-braces check + /// after any programmatic override. + pub fn validate(&self) -> Result<()> { if self.chain.is_empty() { return Err(ConfigError::Validation("no [chain.*] entries".into())); } + // Private-mempool gate: every configured chain must either carry + // a `private_rpc_url` or explicitly opt in to the public mempool + // via `allow_public_mempool = true`. Applying the check per + // chain (rather than only per deployed liquidator) means a + // misconfigured chain can never fall back to public broadcast + // later in the pipeline. + for (name, c) in &self.chain { + if c.private_rpc_url.is_none() && !c.allow_public_mempool { + return Err(ConfigError::PrivateRpcRequired { + chain: name.clone(), + }); + } + } if self.bot.near_liq_threshold_bps <= self.bot.liquidatable_threshold_bps { return Err(ConfigError::Validation(format!( "near_liq_threshold_bps ({}) must be > liquidatable_threshold_bps ({})", @@ -433,3 +511,120 @@ fn is_valid_env_name(s: &str) -> bool { } chars.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_') } + +#[cfg(test)] +mod private_rpc_tests { + //! Tests for the private-RPC gate and secret redaction on + //! `ChainConfig`. These tests are isolated from the file-loading + //! `substitute_env_vars` path so they do not race with any other + //! `std::env::set_var` usage in the crate. + + use super::*; + use secrecy::ExposeSecret; + + fn chain_cfg(private_rpc: Option<&str>, allow_public: bool) -> ChainConfig { + ChainConfig { + chain_id: 56, + ws_url: "wss://example/ws".into(), + http_url: "https://example/http".into(), + priority_fee_gwei: 1, + private_rpc_url: private_rpc.map(|s| SecretString::from(s.to_string())), + private_rpc_auth: None, + allow_public_mempool: allow_public, + } + } + + fn base(chain: ChainConfig) -> Config { + let mut chains = HashMap::new(); + chains.insert("bnb".to_string(), chain); + Config { + bot: BotConfig { + min_profit_usd_1e6: 5_000_000, + max_gas_wei: U256::from(3_000_000_000u64), + scan_interval_ms: 1000, + liquidatable_threshold_bps: 10_000, + near_liq_threshold_bps: 10_500, + hot_scan_blocks: 1, + warm_scan_blocks: 10, + cold_scan_blocks: 100, + signer_key: None, + }, + chain: chains, + protocol: HashMap::new(), + flashloan: HashMap::new(), + liquidator: HashMap::new(), + chainlink: HashMap::new(), + } + } + + #[test] + fn validate_rejects_chain_without_private_rpc() { + let cfg = base(chain_cfg(None, false)); + let err = cfg.validate().expect_err("must refuse public mempool"); + match err { + ConfigError::PrivateRpcRequired { chain } => assert_eq!(chain, "bnb"), + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn validate_allows_public_mempool_opt_in() { + let cfg = base(chain_cfg(None, true)); + cfg.validate().expect("opt-in must be honoured"); + } + + #[test] + fn validate_passes_with_private_rpc_configured() { + let cfg = base(chain_cfg(Some("https://private.example"), false)); + cfg.validate().expect("private rpc present -> valid"); + } + + #[test] + fn debug_redacts_private_rpc_url_and_auth() { + let mut c = chain_cfg(Some("https://key.example/?auth=SUPER_SECRET_KEY"), false); + c.private_rpc_auth = Some(SecretString::from("SECRET_TOKEN".to_string())); + let dbg = format!("{c:?}"); + assert!( + !dbg.contains("SUPER_SECRET_KEY"), + "private_rpc_url leaked: {dbg}" + ); + assert!(!dbg.contains("SECRET_TOKEN"), "auth token leaked: {dbg}"); + assert!( + dbg.contains(""), + "redaction marker missing: {dbg}" + ); + } + + #[test] + fn deser_treats_empty_private_rpc_as_unset() { + // Simulates the env-substitution result when + // `CHARON_BSC_PRIVATE_RPC_URL=` is blank: the string reaches + // serde as `""`, which must collapse to `None` so the + // `PrivateRpcRequired` gate fires instead of constructing a + // bogus empty-URL submitter. + let toml_src = r#" + chain_id = 56 + ws_url = "wss://x/y" + http_url = "https://x/y" + private_rpc_url = "" + private_rpc_auth = "" + allow_public_mempool = true + "#; + let c: ChainConfig = toml::from_str(toml_src).expect("parse"); + assert!(c.private_rpc_url.is_none()); + assert!(c.private_rpc_auth.is_none()); + } + + #[test] + fn deser_keeps_non_empty_private_rpc() { + let toml_src = r#" + chain_id = 56 + ws_url = "wss://x/y" + http_url = "https://x/y" + private_rpc_url = "https://priv.example/rpc" + "#; + let c: ChainConfig = toml::from_str(toml_src).expect("parse"); + let url = c.private_rpc_url.expect("url present"); + assert_eq!(url.expose_secret(), "https://priv.example/rpc"); + } +} diff --git a/crates/charon-executor/Cargo.toml b/crates/charon-executor/Cargo.toml index d667c2e..37cebe0 100644 --- a/crates/charon-executor/Cargo.toml +++ b/crates/charon-executor/Cargo.toml @@ -12,9 +12,14 @@ anyhow = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } tokio = { workspace = true } +secrecy = { workspace = true } +url = { workspace = true } +reqwest = { workspace = true } [dev-dependencies] dotenvy = { workspace = true } +httpmock = "0.7" +tokio = { workspace = true, features = ["test-util"] } [lints] workspace = true diff --git a/crates/charon-executor/src/lib.rs b/crates/charon-executor/src/lib.rs index e9092d7..628d0f0 100644 --- a/crates/charon-executor/src/lib.rs +++ b/crates/charon-executor/src/lib.rs @@ -13,6 +13,7 @@ pub mod builder; pub mod gas; pub mod nonce; pub mod simulation; +pub mod submit; pub use builder::{BuilderError, ICharonLiquidator, TxBuilder}; pub use gas::{ @@ -20,3 +21,4 @@ pub use gas::{ }; pub use nonce::{NonceError, NonceManager}; pub use simulation::{SimulationError, Simulator}; +pub use submit::{DEFAULT_SUBMIT_TIMEOUT, SubmitError, Submitter}; diff --git a/crates/charon-executor/src/submit.rs b/crates/charon-executor/src/submit.rs new file mode 100644 index 0000000..253d1b9 --- /dev/null +++ b/crates/charon-executor/src/submit.rs @@ -0,0 +1,530 @@ +//! Private-RPC transaction submitter. +//! +//! Thin wrapper around `eth_sendRawTransaction`. Primary job: post the +//! raw signed bytes produced by [`crate::builder::TxBuilder::sign`] to +//! a private-RPC endpoint (bloxroute / blocknative on BSC, sequencer +//! URLs on L2s) so pending transactions never hit the public mempool. +//! +//! # Safety invariants +//! +//! 1. **No public-mempool fallback.** [`Submitter::connect`] takes the +//! private URL as an explicit, non-optional argument. Callers must +//! obtain it from `ChainConfig::private_rpc_url`; the opt-out lives +//! in [`charon_core::Config::validate`] behind the +//! `allow_public_mempool` flag and must never be worked around here. +//! 2. **HTTPS / WSS only.** `http://`, `ws://`, missing scheme, and +//! exotic schemes are rejected at connect time. Sending signed +//! calldata over plaintext hands it to anyone on the wire. +//! 3. **Single-shot submission.** `submit()` makes exactly one RPC +//! attempt, then returns. Retries (and the staleness decision that +//! comes with them) belong to the caller, which also owns the +//! opportunity queue TTL and re-quoting logic. +//! 4. **Secrets stay in `SecretString`.** URL and auth header are held +//! in `secrecy::SecretString`; `Debug` is implemented manually to +//! redact them. `expose_secret()` is called exactly once, at +//! transport construction, and never passed to a tracing macro. + +use std::time::Duration; + +use alloy::primitives::{Bytes, TxHash}; +use alloy::providers::{Provider, ProviderBuilder, RootProvider}; +use alloy::pubsub::PubSubFrontend; +use alloy::rpc::client::ClientBuilder; +use alloy::transports::Authorization; +use alloy::transports::http::Http; +use alloy::transports::ws::WsConnect; +use alloy::transports::{BoxTransport, RpcError, TransportError}; +use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderValue}; +use secrecy::{ExposeSecret, SecretString}; +use tracing::{info, warn}; + +/// Default submission timeout (6 s ≈ 2 BSC blocks). +pub const DEFAULT_SUBMIT_TIMEOUT: Duration = Duration::from_secs(6); + +/// Typed failure modes surfaced by the submitter. +/// +/// The enum is `#[non_exhaustive]` so new variants (e.g. circuit-breaker +/// trip, vendor-specific nonce-gap response) can land without a breaking +/// change to callers that already match exhaustively. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum SubmitError { + /// Submission did not complete within the timeout. The caller + /// owns the retry/drop decision; the submitter does not + /// second-guess it. + #[error("submission timed out after {0:?}")] + Timeout(Duration), + + /// RPC returned a non-timeout error (revert, bad nonce, bad + /// signature, 4xx, rate-limit). Deterministic and not worth + /// retrying on the same inputs. + #[error("rpc rejected: {0}")] + RpcRejected(String), + + /// Transport-level failure (TCP reset, TLS error, DNS, websocket + /// close, 5xx). The caller should rebuild the `Submitter` on the + /// next tick — the existing provider may be poisoned. + #[error("connection lost: {0}")] + ConnectionLost(#[source] TransportError), + + /// URL scheme is not `https://` or `wss://`. Plaintext submission + /// of signed calldata is never acceptable. + #[error("insecure scheme: {0}")] + InsecureScheme(String), + + /// URL could not be parsed, or the auth header value contained + /// characters that cannot appear in an HTTP header. + #[error("invalid endpoint configuration: {0}")] + InvalidEndpoint(String), +} + +/// One of the two underlying transports the submitter can hold. +enum Inner { + Http(RootProvider), + Ws(RootProvider), +} + +/// Transaction submitter bound to one private RPC endpoint. +/// +/// `Debug` is implemented manually so the raw endpoint URL (which may +/// embed an API key in the path or query string) never appears in logs +/// or panic traces. +pub struct Submitter { + inner: Inner, + /// Sanitised label for logs: scheme + host only, no path / query. + endpoint_label: String, + timeout: Duration, +} + +impl std::fmt::Debug for Submitter { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Submitter") + .field("endpoint", &self.endpoint_label) + .field("timeout", &self.timeout) + .finish() + } +} + +impl Submitter { + /// Connect to a private RPC endpoint. + /// + /// - `url` is the private-RPC URL. Must use `https://` or `wss://`; + /// `http://`, `ws://`, missing scheme, and exotic schemes are + /// rejected with [`SubmitError::InsecureScheme`]. + /// - `auth` is an optional bearer token. When provided, every HTTP + /// request carries `Authorization: Bearer `. For WSS the + /// header is attached during the handshake via [`WsConnect`]. + /// - `timeout` bounds a single submission attempt. + /// + /// The URL stays inside the `SecretString` the caller owns; this + /// function derives a host-only label for logging and calls + /// `expose_secret()` exactly once, at transport construction. + pub async fn connect( + url: &SecretString, + auth: Option<&SecretString>, + timeout: Duration, + ) -> Result { + let raw = url.expose_secret(); + let parsed = url::Url::parse(raw) + .map_err(|e| SubmitError::InvalidEndpoint(format!("invalid URL: {e}")))?; + + let scheme = parsed.scheme(); + if scheme != "https" && scheme != "wss" { + return Err(SubmitError::InsecureScheme(scheme.to_string())); + } + + // Host-only label for logging — hides API keys that vendors + // embed in the path or query string. + let endpoint_label = match parsed.host_str() { + Some(h) => match parsed.port() { + Some(p) => format!("{scheme}://{h}:{p}"), + None => format!("{scheme}://{h}"), + }, + None => scheme.to_string(), + }; + + let inner = match scheme { + "https" => { + let client = build_reqwest_client(auth)?; + let http = Http::with_client(client, parsed); + let is_local = false; + let rpc_client = ClientBuilder::default().transport(http, is_local); + let provider: RootProvider = + ProviderBuilder::new().on_client(rpc_client.boxed()); + Inner::Http(provider) + } + "wss" => { + let mut connect = WsConnect::new(raw.to_string()); + if let Some(a) = auth { + connect = connect.with_auth(Authorization::bearer(a.expose_secret())); + } + let provider = ProviderBuilder::new() + .on_ws(connect) + .await + .map_err(SubmitError::ConnectionLost)?; + Inner::Ws(provider) + } + other => return Err(SubmitError::InsecureScheme(other.to_string())), + }; + + info!( + endpoint = %endpoint_label, + timeout_secs = timeout.as_secs(), + auth = auth.is_some(), + "submitter ready" + ); + Ok(Self { + inner, + endpoint_label, + timeout, + }) + } + + /// Host-only endpoint label — safe for logs; the secret part of + /// the URL never reaches this accessor. + pub fn endpoint(&self) -> &str { + &self.endpoint_label + } + + /// Submit raw signed transaction bytes. Single attempt, no retry. + /// + /// The submitter is deliberately single-shot. Whether a timed-out + /// broadcast is still worth re-sending (same price, same health + /// factor, same gas ceiling) is a pipeline-level decision owned + /// by the caller along with the opportunity queue TTL. + /// + /// Error mapping: + /// - elapsed deadline -> [`SubmitError::Timeout`] + /// - JSON-RPC level error / 4xx / 429 -> [`SubmitError::RpcRejected`] + /// - transport-level error / 5xx -> [`SubmitError::ConnectionLost`] + pub async fn submit(&self, raw: Bytes) -> Result { + // Each transport's pending-transaction builder has a + // different generic parameter, so `async move { ... }` over + // both arms gives incompatible types. Project to TxHash + // inside each arm, yielding a uniform `Result`. + let fut = async { + match &self.inner { + Inner::Http(p) => p + .send_raw_transaction(&raw) + .await + .map(|pending| *pending.tx_hash()), + Inner::Ws(p) => p + .send_raw_transaction(&raw) + .await + .map(|pending| *pending.tx_hash()), + } + }; + + match tokio::time::timeout(self.timeout, fut).await { + Ok(Ok(hash)) => { + info!(endpoint = %self.endpoint_label, %hash, "tx submitted"); + Ok(hash) + } + Ok(Err(err)) => { + warn!( + endpoint = %self.endpoint_label, + error = %err, + "submit rejected by RPC" + ); + Err(classify_transport_error(err)) + } + Err(_) => { + warn!( + endpoint = %self.endpoint_label, + timeout_secs = self.timeout.as_secs(), + "submit timed out" + ); + Err(SubmitError::Timeout(self.timeout)) + } + } + } +} + +/// Build a reqwest client that attaches `Authorization: Bearer ` +/// to every request when `auth` is `Some`. The `HeaderValue` is marked +/// sensitive so reqwest / hyper omit it from their own debug output. +fn build_reqwest_client(auth: Option<&SecretString>) -> Result { + let mut builder = reqwest::Client::builder(); + if let Some(token) = auth { + let value = format!("Bearer {}", token.expose_secret()); + let mut header_value = HeaderValue::from_str(&value).map_err(|e| { + SubmitError::InvalidEndpoint(format!("invalid Authorization header: {e}")) + })?; + header_value.set_sensitive(true); + let mut headers = HeaderMap::new(); + headers.insert(AUTHORIZATION, header_value); + builder = builder.default_headers(headers); + } + builder + .build() + .map_err(|e| SubmitError::InvalidEndpoint(format!("reqwest build failed: {e}"))) +} + +/// Split an alloy `TransportError` into an RPC-level rejection vs. a +/// connection-level loss. +/// +/// The distinction matters: `RpcRejected` means the wire is fine but +/// the node did not like the request (revert, bad nonce, 4xx, 429). +/// The caller should not rebuild the submitter. `ConnectionLost` +/// means the caller should drop this submitter and reconnect on the +/// next tick. +fn classify_transport_error(err: TransportError) -> SubmitError { + match err { + RpcError::ErrorResp(ref payload) => { + SubmitError::RpcRejected(format!("code={} msg={}", payload.code, payload.message)) + } + RpcError::DeserError { .. } | RpcError::SerError(_) | RpcError::NullResp => { + SubmitError::RpcRejected(err.to_string()) + } + RpcError::Transport(ref kind) => { + let msg = kind.to_string(); + // alloy renders HTTP status errors as "HTTP error ...". + // 4xx (including 429) is a deterministic rejection; 5xx and + // everything else (TCP reset, TLS, DNS) means the caller + // should reconnect. + let is_4xx = msg.contains("429") + || msg.contains("400") + || msg.contains("401") + || msg.contains("403") + || msg.contains("404") + || msg.contains("408") + || msg.contains("413") + || msg.contains("415") + || msg.contains("422"); + if is_4xx { + SubmitError::RpcRejected(msg) + } else { + SubmitError::ConnectionLost(err) + } + } + _ => SubmitError::ConnectionLost(err), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use httpmock::prelude::*; + + fn sec(s: &str) -> SecretString { + SecretString::from(s.to_string()) + } + + // ---- scheme enforcement --------------------------------------------- + + #[tokio::test] + async fn connect_rejects_plain_http_scheme() { + let err = Submitter::connect(&sec("http://example.com/rpc"), None, DEFAULT_SUBMIT_TIMEOUT) + .await + .expect_err("http:// must be rejected"); + assert!(matches!(err, SubmitError::InsecureScheme(ref s) if s == "http")); + } + + #[tokio::test] + async fn connect_rejects_plain_ws_scheme() { + let err = Submitter::connect(&sec("ws://example.com/rpc"), None, DEFAULT_SUBMIT_TIMEOUT) + .await + .expect_err("ws:// must be rejected"); + assert!(matches!(err, SubmitError::InsecureScheme(ref s) if s == "ws")); + } + + #[tokio::test] + async fn connect_rejects_exotic_scheme() { + let err = Submitter::connect(&sec("ftp://example.com/"), None, DEFAULT_SUBMIT_TIMEOUT) + .await + .expect_err("ftp must be rejected"); + assert!(matches!(err, SubmitError::InsecureScheme(ref s) if s == "ftp")); + } + + #[tokio::test] + async fn connect_rejects_missing_scheme() { + let err = Submitter::connect(&sec("example.com/rpc"), None, DEFAULT_SUBMIT_TIMEOUT) + .await + .expect_err("missing scheme must be rejected"); + // url::Url::parse either fails outright (InvalidEndpoint) or + // returns a non-http(s) scheme (InsecureScheme). Both are safe. + assert!(matches!( + err, + SubmitError::InvalidEndpoint(_) | SubmitError::InsecureScheme(_) + )); + } + + #[test] + fn default_timeout_is_six_seconds() { + assert_eq!(DEFAULT_SUBMIT_TIMEOUT, Duration::from_secs(6)); + } + + // ---- Debug redaction ------------------------------------------------- + + #[tokio::test] + async fn debug_impl_does_not_expose_raw_url() { + // Hand-construct a submitter (bypasses scheme check) so we + // can prove Debug only reveals the scrubbed label. + let s = Submitter { + inner: Inner::Http(boxed_http_provider("http://127.0.0.1:1/")), + endpoint_label: "https://private.example".to_string(), + timeout: DEFAULT_SUBMIT_TIMEOUT, + }; + let dbg = format!("{s:?}"); + assert!(dbg.contains("https://private.example")); + assert!( + !dbg.contains("127.0.0.1"), + "raw transport URL leaked: {dbg}" + ); + } + + // ---- httpmock-backed behaviour tests -------------------------------- + // + // httpmock serves plain HTTP, which the public `Submitter::connect` + // rejects by design. These tests build the provider directly so we + // can exercise the hash-parsing and error-classification paths that + // sit underneath `submit()`. Scheme enforcement is covered by the + // unit tests above. + + /// Construct a `RootProvider` pointing at an + /// http:// URL so the httpmock-backed tests can bypass the + /// https-only check in `Submitter::connect`. + fn boxed_http_provider(url: &str) -> RootProvider { + let parsed: url::Url = url.parse().expect("valid http url"); + let http = Http::with_client(reqwest::Client::new(), parsed); + let rpc_client = ClientBuilder::default().transport(http, false); + ProviderBuilder::new().on_client(rpc_client.boxed()) + } + + fn build_test_submitter(url: &str, timeout: Duration) -> Submitter { + Submitter { + inner: Inner::Http(boxed_http_provider(url)), + endpoint_label: url.to_string(), + timeout, + } + } + + #[tokio::test] + async fn submit_parses_hash_from_valid_rpc_response() { + let server = MockServer::start_async().await; + let _m = server + .mock_async(|when, then| { + when.method(POST).path("/"); + then.status(200) + .header("content-type", "application/json") + .body( + r#"{"jsonrpc":"2.0","id":0,"result":"0x1111111111111111111111111111111111111111111111111111111111111111"}"#, + ); + }) + .await; + + let submitter = build_test_submitter(&server.url("/"), DEFAULT_SUBMIT_TIMEOUT); + let hash = submitter + .submit(Bytes::from_static(&[0x02, 0xc0])) + .await + .expect("submit must parse hash"); + assert_eq!( + format!("{hash:?}"), + "0x1111111111111111111111111111111111111111111111111111111111111111" + ); + } + + #[tokio::test] + async fn submit_maps_429_to_rpc_rejected() { + let server = MockServer::start_async().await; + let _m = server + .mock_async(|when, then| { + when.method(POST).path("/"); + then.status(429) + .header("content-type", "application/json") + .body(r#"{"error":"rate limited"}"#); + }) + .await; + + let submitter = build_test_submitter(&server.url("/"), DEFAULT_SUBMIT_TIMEOUT); + let err = submitter + .submit(Bytes::from_static(&[0x02, 0xc0])) + .await + .expect_err("429 must bubble up"); + assert!( + matches!(err, SubmitError::RpcRejected(_)), + "expected RpcRejected, got {err:?}" + ); + } + + #[tokio::test] + async fn submit_maps_jsonrpc_error_to_rpc_rejected() { + let server = MockServer::start_async().await; + let _m = server + .mock_async(|when, then| { + when.method(POST).path("/"); + then.status(200) + .header("content-type", "application/json") + .body( + r#"{"jsonrpc":"2.0","id":0,"error":{"code":-32000,"message":"nonce too low"}}"#, + ); + }) + .await; + + let submitter = build_test_submitter(&server.url("/"), DEFAULT_SUBMIT_TIMEOUT); + let err = submitter + .submit(Bytes::from_static(&[0x02, 0xc0])) + .await + .expect_err("node error must surface"); + match err { + SubmitError::RpcRejected(msg) => { + assert!(msg.contains("nonce too low"), "unexpected msg: {msg}"); + } + other => panic!("expected RpcRejected, got {other:?}"), + } + } + + #[tokio::test(flavor = "current_thread", start_paused = true)] + async fn submit_times_out_on_single_attempt() { + // Paused tokio clock lets us assert single-shot timeout + // without a real 6 s wait. + let server = MockServer::start_async().await; + let _m = server + .mock_async(|when, then| { + when.method(POST).path("/"); + then.status(200).delay(Duration::from_secs(3600)).body("{}"); + }) + .await; + + let submitter = build_test_submitter(&server.url("/"), Duration::from_millis(50)); + + let handle = + tokio::spawn(async move { submitter.submit(Bytes::from_static(&[0x02, 0xc0])).await }); + + tokio::time::advance(Duration::from_millis(100)).await; + + let err = handle + .await + .expect("task must not panic") + .expect_err("must time out"); + assert!( + matches!(err, SubmitError::Timeout(d) if d == Duration::from_millis(50)), + "expected Timeout(50ms), got {err:?}" + ); + } + + #[tokio::test] + async fn submit_returns_connection_lost_when_transport_fails() { + // Point at a bound but closed TCP port on loopback: grab a + // listener, read its address, then drop the listener so the + // connect attempt gets an immediate ECONNREFUSED. This + // exercises the same code path a mid-flight reset would hit + // (transport error, not RPC rejection), and must surface as + // ConnectionLost so the caller rebuilds the submitter on + // the next tick. + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + drop(listener); + let url = format!("http://{addr}/"); + + let submitter = build_test_submitter(&url, DEFAULT_SUBMIT_TIMEOUT); + let err = submitter + .submit(Bytes::from_static(&[0x02, 0xc0])) + .await + .expect_err("closed port must fail"); + assert!( + matches!(err, SubmitError::ConnectionLost(_)), + "expected ConnectionLost, got {err:?}" + ); + } +}