From dc16c47c915d4b6403d3162cf855e6e10e4289f5 Mon Sep 17 00:00:00 2001 From: CryptCortex Date: Mon, 12 May 2025 13:29:37 +0100 Subject: [PATCH 01/14] feat: add auctioneer-api dependency and update package versions --- Cargo.lock | 525 ++++++++++++++++++++++++++++++++++++- Cargo.toml | 22 +- mantis-cli/Cargo.toml | 1 + mantis-cli/src/main.rs | 8 +- mantis-sdk/Cargo.toml | 10 + mantis-sdk/src/ethereum.rs | 9 + rust-toolchain.toml | 4 + 7 files changed, 560 insertions(+), 19 deletions(-) create mode 100644 rust-toolchain.toml diff --git a/Cargo.lock b/Cargo.lock index 56d1d82..7f99020 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1132,6 +1132,15 @@ dependencies = [ "syn 2.0.101", ] +[[package]] +name = "arbitrary" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "arc-swap" version = "1.7.1" @@ -1461,6 +1470,12 @@ dependencies = [ "rustc_version 0.4.1", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "atty" version = "0.2.14" @@ -1472,6 +1487,30 @@ dependencies = [ "winapi 0.3.9", ] +[[package]] +name = "auctioneer-api" +version = "0.1.0" +source = "git+ssh://git@github.com/ComposableFi/mantis-backend.git?branch=main#edeed8e144b40517d7b792ef622c917ab00e7a8d" +dependencies = [ + "alloy", + "anyhow", + "axum 0.8.4", + "num 0.4.3", + "regex", + "serde", + "serde_json", + "solana-sdk", + "strum 0.26.3", + "strum_macros 0.26.4", + "thiserror 2.0.12", + "tracing", + "utoipa", + "utoipa-swagger-ui", + "uuid", + "validator", + "zip", +] + [[package]] name = "auto_impl" version = "1.3.0" @@ -1505,7 +1544,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b829e4e32b91e643de6eafe82b1d90675f5874230191a4ffbc1b336dec4d6bf" dependencies = [ "async-trait", - "axum-core", + "axum-core 0.3.4", "bitflags 1.3.2", "bytes", "futures-util", @@ -1513,7 +1552,7 @@ dependencies = [ "http-body 0.4.6", "hyper 0.14.32", "itoa", - "matchit", + "matchit 0.7.3", "memchr", "mime", "percent-encoding 2.3.1", @@ -1526,6 +1565,44 @@ dependencies = [ "tower-service", ] +[[package]] +name = "axum" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5" +dependencies = [ + "axum-core 0.5.2", + "axum-macros", + "base64 0.22.1", + "bytes", + "form_urlencoded", + "futures-util", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", + "hyper 1.6.0", + "hyper-util", + "itoa", + "matchit 0.8.4", + "memchr", + "mime", + "percent-encoding 2.3.1", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper 1.0.2", + "tokio", + "tokio-tungstenite 0.26.2", + "tower 0.5.2", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "axum-core" version = "0.3.4" @@ -1543,6 +1620,37 @@ dependencies = [ "tower-service", ] +[[package]] +name = "axum-core" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68464cd0412f486726fb3373129ef5d2993f90c34bc2bc1c1e9943b2f4fc7ca6" +dependencies = [ + "bytes", + "futures-core", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper 1.0.2", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + [[package]] name = "backoff" version = "0.4.0" @@ -1920,6 +2028,15 @@ dependencies = [ "libc", ] +[[package]] +name = "bzip2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" +dependencies = [ + "bzip2-sys", +] + [[package]] name = "bzip2-sys" version = "0.1.13+1.0.8" @@ -2339,6 +2456,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + [[package]] name = "crc32fast" version = "1.4.2" @@ -2541,6 +2673,12 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" +[[package]] +name = "deflate64" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da692b8d1080ea3045efaab14434d40468c3d8657e42abddfffca87b428f4c1b" + [[package]] name = "der" version = "0.7.10" @@ -2602,6 +2740,17 @@ dependencies = [ "syn 2.0.101", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30542c1ad912e0e3d22a1935c290e12e8a29d704a420177a31faad4a601a0800" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + [[package]] name = "derive_more" version = "0.99.20" @@ -3479,6 +3628,25 @@ dependencies = [ "tracing", ] +[[package]] +name = "h2" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9421a676d1b147b16b82c9225157dc629087ef8ec4d5e2960f9437a90dac0a5" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.3.1", + "indexmap 2.9.0", + "slab", + "tokio", + "tokio-util 0.7.15", + "tracing", +] + [[package]] name = "hash32" version = "0.2.1" @@ -3735,7 +3903,7 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "h2", + "h2 0.3.26", "http 0.2.12", "http-body 0.4.6", "httparse", @@ -3758,9 +3926,11 @@ dependencies = [ "bytes", "futures-channel", "futures-util", + "h2 0.4.10", "http 1.3.1", "http-body 1.0.1", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -3800,6 +3970,23 @@ dependencies = [ "tokio-rustls 0.24.1", ] +[[package]] +name = "hyper-rustls" +version = "0.27.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d191583f3da1305256f22463b9bb0471acad48a4e534a5218b9963e9c1f59b2" +dependencies = [ + "futures-util", + "http 1.3.1", + "hyper 1.6.0", + "hyper-util", + "rustls 0.23.27", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.2", + "tower-service", +] + [[package]] name = "hyper-timeout" version = "0.4.1" @@ -4638,6 +4825,27 @@ dependencies = [ "libc", ] +[[package]] +name = "lzma-rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e" +dependencies = [ + "byteorder", + "crc", +] + +[[package]] +name = "lzma-sys" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + [[package]] name = "macro-string" version = "0.1.4" @@ -4678,6 +4886,7 @@ dependencies = [ "spl-token 8.0.0", "spl-token-2022 8.0.1", "tokio", + "tracing-subscriber", ] [[package]] @@ -4689,10 +4898,15 @@ dependencies = [ "anchor-lang", "anchor-spl", "anyhow", + "auctioneer-api", "base64 0.22.1", "chrono", + "futures 0.3.31", "num 0.4.3", "rand 0.8.5", + "reqwest 0.12.15", + "serde", + "serde_json", "solana-client", "solana-logger", "solana-program", @@ -4706,8 +4920,13 @@ dependencies = [ "spl-token-2022 8.0.1", "strum 0.26.3", "strum_macros 0.26.4", + "thiserror 2.0.12", "tokio", + "tokio-tungstenite 0.26.2", "tracing", + "tracing-subscriber", + "url 2.5.4", + "uuid", ] [[package]] @@ -4722,6 +4941,12 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "memchr" version = "2.7.4" @@ -4929,6 +5154,16 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi 0.3.9", +] + [[package]] name = "num" version = "0.2.1" @@ -4976,6 +5211,7 @@ checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" dependencies = [ "num-integer", "num-traits", + "serde", ] [[package]] @@ -4995,6 +5231,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" dependencies = [ "num-traits", + "serde", ] [[package]] @@ -5055,6 +5292,7 @@ dependencies = [ "num-bigint 0.4.6", "num-integer", "num-traits", + "serde", ] [[package]] @@ -5220,6 +5458,12 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + [[package]] name = "parity-scale-codec" version = "3.7.4" @@ -6086,11 +6330,11 @@ dependencies = [ "encoding_rs", "futures-core", "futures-util", - "h2", + "h2 0.3.26", "http 0.2.12", "http-body 0.4.6", "hyper 0.14.32", - "hyper-rustls", + "hyper-rustls 0.24.2", "hyper-tls 0.5.0", "ipnet", "js-sys", @@ -6107,7 +6351,7 @@ dependencies = [ "serde_json", "serde_urlencoded", "sync_wrapper 0.1.2", - "system-configuration", + "system-configuration 0.5.1", "tokio", "tokio-native-tls", "tokio-rustls 0.24.1", @@ -6129,12 +6373,15 @@ checksum = "d19c46a6fdd48bc4dab94b6103fccc55d34c67cc0ad04653aad4ea2a07cd7bbb" dependencies = [ "base64 0.22.1", "bytes", + "encoding_rs", "futures-core", "futures-util", + "h2 0.4.10", "http 1.3.1", "http-body 1.0.1", "http-body-util", "hyper 1.6.0", + "hyper-rustls 0.27.5", "hyper-tls 0.6.0", "hyper-util", "ipnet", @@ -6150,6 +6397,7 @@ dependencies = [ "serde_json", "serde_urlencoded", "sync_wrapper 1.0.2", + "system-configuration 0.6.1", "tokio", "tokio-native-tls", "tower 0.5.2", @@ -6292,6 +6540,40 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48fd7bd8a6377e15ad9d42a8ec25371b94ddc67abe7c8b9127bec79bebaaae18" +[[package]] +name = "rust-embed" +version = "8.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60e425e204264b144d4c929d126d0de524b40a961686414bab5040f7465c71be" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bf418c9a2e3f6663ca38b8a7134cc2c2167c9d69688860e8961e3faa731702e" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "syn 2.0.101", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d55b95147fe01265d06b3955db798bdaed52e60e2211c41137701b3aba8e21" +dependencies = [ + "sha2 0.10.9", + "walkdir", +] + [[package]] name = "rustc-demangle" version = "0.1.24" @@ -6700,6 +6982,16 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59fab13f937fa393d08645bf3a84bdfe86e296747b506ada67bb15f10f218b2a" +dependencies = [ + "itoa", + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -6862,6 +7154,12 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + [[package]] name = "simpl" version = "0.1.0" @@ -7047,7 +7345,7 @@ dependencies = [ "bv", "bytemuck", "bytemuck_derive", - "bzip2", + "bzip2 0.4.4", "crossbeam-channel", "dashmap 5.5.3", "index_list", @@ -8318,7 +8616,7 @@ dependencies = [ "assert_matches", "bincode", "bitflags 2.9.0", - "bzip2", + "bzip2 0.4.4", "chrono", "chrono-humanize", "crossbeam-channel", @@ -9309,7 +9607,7 @@ dependencies = [ "blake3", "bv", "bytemuck", - "bzip2", + "bzip2 0.4.4", "crossbeam-channel", "dashmap 5.5.3", "dir-diff", @@ -9787,7 +10085,7 @@ dependencies = [ "backoff", "bincode", "bytes", - "bzip2", + "bzip2 0.4.4", "enum-iterator", "flate2", "futures 0.3.31", @@ -11559,7 +11857,18 @@ checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" dependencies = [ "bitflags 1.3.2", "core-foundation 0.9.4", - "system-configuration-sys", + "system-configuration-sys 0.5.0", +] + +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags 2.9.0", + "core-foundation 0.9.4", + "system-configuration-sys 0.6.0", ] [[package]] @@ -11572,6 +11881,16 @@ dependencies = [ "libc", ] +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "tap" version = "1.0.1" @@ -11941,6 +12260,20 @@ dependencies = [ "webpki-roots 0.26.11", ] +[[package]] +name = "tokio-tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +dependencies = [ + "futures-util", + "log", + "native-tls", + "tokio", + "tokio-native-tls", + "tungstenite 0.26.2", +] + [[package]] name = "tokio-util" version = "0.6.10" @@ -12004,12 +12337,12 @@ checksum = "3082666a3a6433f7f511c7192923fa1fe07c69332d3c6a2e6bb040b569199d5a" dependencies = [ "async-stream", "async-trait", - "axum", + "axum 0.6.20", "base64 0.21.7", "bytes", "futures-core", "futures-util", - "h2", + "h2 0.3.26", "http 0.2.12", "http-body 0.4.6", "hyper 0.14.32", @@ -12073,6 +12406,7 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -12120,6 +12454,17 @@ dependencies = [ "valuable", ] +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + [[package]] name = "tracing-opentelemetry" version = "0.17.4" @@ -12139,9 +12484,12 @@ version = "0.3.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" dependencies = [ + "nu-ansi-term", "sharded-slab", + "smallvec", "thread_local", "tracing-core", + "tracing-log", ] [[package]] @@ -12208,6 +12556,24 @@ dependencies = [ "utf-8", ] +[[package]] +name = "tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +dependencies = [ + "bytes", + "data-encoding", + "http 1.3.1", + "httparse", + "log", + "native-tls", + "rand 0.9.1", + "sha1", + "thiserror 2.0.12", + "utf-8", +] + [[package]] name = "typenum" version = "1.18.0" @@ -12370,6 +12736,88 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "utoipa" +version = "5.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435c6f69ef38c9017b4b4eea965dfb91e71e53d869e896db40d1cf2441dd75c0" +dependencies = [ + "indexmap 2.9.0", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "5.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77d306bc75294fd52f3e99b13ece67c02c1a2789190a6f31d32f736624326f7" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn 2.0.101", +] + +[[package]] +name = "utoipa-swagger-ui" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "161166ec520c50144922a625d8bc4925cc801b2dda958ab69878527c0e5c5d61" +dependencies = [ + "axum 0.8.4", + "base64 0.22.1", + "mime_guess", + "regex", + "rust-embed", + "serde", + "serde_json", + "url 2.5.4", + "utoipa", + "zip", +] + +[[package]] +name = "uuid" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9" +dependencies = [ + "getrandom 0.3.3", + "serde", +] + +[[package]] +name = "validator" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0b4a29d8709210980a09379f27ee31549b73292c87ab9899beee1c0d3be6303" +dependencies = [ + "idna 1.0.3", + "once_cell", + "regex", + "serde", + "serde_derive", + "serde_json", + "url 2.5.4", + "validator_derive", +] + +[[package]] +name = "validator_derive" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bac855a2ce6f843beb229757e6e570a42e837bcb15e5f449dd48d5747d41bf77" +dependencies = [ + "darling", + "once_cell", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.101", +] + [[package]] name = "valuable" version = "0.1.1" @@ -13129,6 +13577,15 @@ dependencies = [ "rustix 1.0.7", ] +[[package]] +name = "xz2" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" +dependencies = [ + "lzma-sys", +] + [[package]] name = "yoke" version = "0.8.0" @@ -13247,6 +13704,48 @@ dependencies = [ "syn 2.0.101", ] +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "aes", + "arbitrary", + "bzip2 0.5.2", + "constant_time_eq", + "crc32fast", + "crossbeam-utils", + "deflate64", + "displaydoc", + "flate2", + "getrandom 0.3.3", + "hmac 0.12.1", + "indexmap 2.9.0", + "lzma-rs", + "memchr", + "pbkdf2 0.12.2", + "sha1", + "thiserror 2.0.12", + "time", + "xz2", + "zeroize", + "zopfli", + "zstd", +] + +[[package]] +name = "zopfli" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edfc5ee405f504cd4984ecc6f14d02d55cfda60fa4b689434ef4102aae150cd7" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zstd" version = "0.13.3" diff --git a/Cargo.toml b/Cargo.toml index 9b2c1b1..be96262 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,9 +12,9 @@ rust-version = "1.84.0" version = "0.1.0" [workspace.dependencies] -anchor-client = { version = "0.31.0", features = ["async"] } -anchor-lang = "0.31.0" -anchor-spl = "0.31.0" +anchor-client = { version = "0.31.1", features = ["async"] } +anchor-lang = "0.31.1" +anchor-spl = "0.31.1" solana-client = "~2.2.1" solana-logger = "~2.2.1" solana-program = "~2.2.1" @@ -29,17 +29,31 @@ spl-token-2022 = { version = "8.0.1", features = ["no-entrypoint"] } alloy = { version = "0.9.2", features = ["full", "node-bindings", "signer-mnemonic"] } anyhow = "1.0.96" -base64 = "0.22.1" +base64 = { version = "0.22.1", features = ["alloc"] } chrono = "0.4.40" clap = { version = "4.5.32", features = ["derive"] } dotenv = "0.15.0" env_logger = { version = "0.11.8" } +futures = "0.3" +futures-util = "0.3" log = { version = "0.4.27" } num = "0.4.3" rand = "0.8.0" +reqwest = { version = "0.12.15", features = ["json"] } +serde = { version = "1.0.219", features = ["derive"] } +serde_json = "1.0.140" strum = { version = "0.26.2", features = ["derive"] } strum_macros = "0.26.4" +thiserror = "2.0.12" tokio = { version = "1.43.0", features = ["full"] } +tokio-tungstenite = { version = "0.26.2", features = ["native-tls"] } tracing = "0.1.41" +tracing-subscriber = "0.3.19" +url = "2.5.4" +uuid = { version = "1.16.0", features = ["v4"] } +auctioneer-api = { git = "ssh://git@github.com/ComposableFi/mantis-backend.git", branch = "main", package = "auctioneer-api" } mantis-sdk = { path = "mantis-sdk" } + +[net] +git-fetch-with-cli = true diff --git a/mantis-cli/Cargo.toml b/mantis-cli/Cargo.toml index 74f4adf..f73cc7c 100644 --- a/mantis-cli/Cargo.toml +++ b/mantis-cli/Cargo.toml @@ -35,3 +35,4 @@ rand = { workspace = true } tokio = { workspace = true } mantis-sdk = { workspace = true } +tracing-subscriber = "0.3.19" diff --git a/mantis-cli/src/main.rs b/mantis-cli/src/main.rs index dd5d7e8..5198f34 100644 --- a/mantis-cli/src/main.rs +++ b/mantis-cli/src/main.rs @@ -115,8 +115,12 @@ struct CancelArgs { token_in: Option, } +use tracing_subscriber; + #[tokio::main(flavor = "current_thread")] async fn main() -> Result<()> { + // Initialize the tracing subscriber to use the RUST_LOG environment variable + tracing_subscriber::fmt::init(); dotenv::dotenv()?; let cli = Cli::parse(); @@ -167,7 +171,7 @@ async fn main() -> Result<()> { false, ) .await - .context("Escrow funds operation failed")?; + .context("Solana escrow funds operation failed")?; println!("Transaction: {}", signature); } @@ -206,7 +210,7 @@ async fn main() -> Result<()> { false, ) .await - .context("Escrow funds operation failed")?; + .context("Ethereum escrow funds operation failed")?; println!("Transaction: {}", receipt.transaction_hash); } diff --git a/mantis-sdk/Cargo.toml b/mantis-sdk/Cargo.toml index ee3ed03..c750efb 100644 --- a/mantis-sdk/Cargo.toml +++ b/mantis-sdk/Cargo.toml @@ -27,9 +27,19 @@ alloy = { workspace = true } anyhow = { workspace = true } base64 = { workspace = true } chrono = { workspace = true } +futures = { workspace = true } num = { workspace = true } rand = { workspace = true } +reqwest = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } strum = { workspace = true } strum_macros = { workspace = true } +thiserror = { workspace = true } tokio = { workspace = true } +tokio-tungstenite = { workspace = true } tracing = { workspace = true } +tracing-subscriber = { workspace = true } +url = { workspace = true } +uuid = { workspace = true } +auctioneer-api = { workspace = true } diff --git a/mantis-sdk/src/ethereum.rs b/mantis-sdk/src/ethereum.rs index 42f34d1..0b5acaa 100644 --- a/mantis-sdk/src/ethereum.rs +++ b/mantis-sdk/src/ethereum.rs @@ -72,6 +72,7 @@ where P: Provider + Clone + WalletProvider, T: Transport + Clone, { + info!("Escrow funds on Ethereum"); let escrow_contract = Escrow::new(escrow_address, provider.clone()); let intent_id = random_intent_id(); @@ -135,6 +136,14 @@ where ) .await?; + info!( + "Escrowed {} of token {} to {} ({:?})", + amount_in, + token_in.to_checksum(None), + escrow_address.to_checksum(None), + tx_hash, + ); + Ok(receipt) } diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..2f24121 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.86.0" +components = ["clippy", "rustfmt"] +targets = ["x86_64-unknown-linux-gnu"] From c3672a9dfd0bc5e01f021a987bee9825733c3d67 Mon Sep 17 00:00:00 2001 From: CryptCortex Date: Tue, 13 May 2025 15:26:54 +0100 Subject: [PATCH 02/14] feat: move auctioneer-api from external dependency to local package --- Cargo.lock | 49 ++--- Cargo.toml | 14 +- mantis-auctioneer-api/Cargo.toml | 27 +++ mantis-auctioneer-api/src/http.rs | 337 ++++++++++++++++++++++++++++ mantis-auctioneer-api/src/lib.rs | 57 +++++ mantis-auctioneer-api/src/ws.rs | 352 ++++++++++++++++++++++++++++++ mantis-sdk/Cargo.toml | 2 +- 7 files changed, 807 insertions(+), 31 deletions(-) create mode 100644 mantis-auctioneer-api/Cargo.toml create mode 100644 mantis-auctioneer-api/src/http.rs create mode 100644 mantis-auctioneer-api/src/lib.rs create mode 100644 mantis-auctioneer-api/src/ws.rs diff --git a/Cargo.lock b/Cargo.lock index 7f99020..d6f9fa8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1487,30 +1487,6 @@ dependencies = [ "winapi 0.3.9", ] -[[package]] -name = "auctioneer-api" -version = "0.1.0" -source = "git+ssh://git@github.com/ComposableFi/mantis-backend.git?branch=main#edeed8e144b40517d7b792ef622c917ab00e7a8d" -dependencies = [ - "alloy", - "anyhow", - "axum 0.8.4", - "num 0.4.3", - "regex", - "serde", - "serde_json", - "solana-sdk", - "strum 0.26.3", - "strum_macros 0.26.4", - "thiserror 2.0.12", - "tracing", - "utoipa", - "utoipa-swagger-ui", - "uuid", - "validator", - "zip", -] - [[package]] name = "auto_impl" version = "1.3.0" @@ -4857,6 +4833,29 @@ dependencies = [ "syn 2.0.101", ] +[[package]] +name = "mantis-auctioneer-api" +version = "0.1.0" +dependencies = [ + "alloy", + "anyhow", + "axum 0.8.4", + "num 0.4.3", + "regex", + "serde", + "serde_json", + "solana-sdk", + "strum 0.26.3", + "strum_macros 0.26.4", + "thiserror 2.0.12", + "tracing", + "utoipa", + "utoipa-swagger-ui", + "uuid", + "validator", + "zip", +] + [[package]] name = "mantis-cli" version = "0.1.0" @@ -4898,10 +4897,10 @@ dependencies = [ "anchor-lang", "anchor-spl", "anyhow", - "auctioneer-api", "base64 0.22.1", "chrono", "futures 0.3.31", + "mantis-auctioneer-api", "num 0.4.3", "rand 0.8.5", "reqwest 0.12.15", diff --git a/Cargo.toml b/Cargo.toml index be96262..fb341d4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] exclude = [] -members = ["mantis-sdk", "mantis-cli"] +members = ["mantis-sdk", "mantis-cli", "mantis-auctioneer-api"] resolver = "2" [workspace.package] @@ -29,6 +29,8 @@ spl-token-2022 = { version = "8.0.1", features = ["no-entrypoint"] } alloy = { version = "0.9.2", features = ["full", "node-bindings", "signer-mnemonic"] } anyhow = "1.0.96" +axum = "0.8.0" +axum-extra = "0.10.1" base64 = { version = "0.22.1", features = ["alloc"] } chrono = "0.4.40" clap = { version = "4.5.32", features = ["derive"] } @@ -39,6 +41,7 @@ futures-util = "0.3" log = { version = "0.4.27" } num = "0.4.3" rand = "0.8.0" +regex = "1.11.1" reqwest = { version = "0.12.15", features = ["json"] } serde = { version = "1.0.219", features = ["derive"] } serde_json = "1.0.140" @@ -50,10 +53,11 @@ tokio-tungstenite = { version = "0.26.2", features = ["native-tls"] } tracing = "0.1.41" tracing-subscriber = "0.3.19" url = "2.5.4" +utoipa = "5.3.1" +utoipa-swagger-ui = "9.0.0" uuid = { version = "1.16.0", features = ["v4"] } -auctioneer-api = { git = "ssh://git@github.com/ComposableFi/mantis-backend.git", branch = "main", package = "auctioneer-api" } +validator = "0.19.0" +zip = "~2.4.2" +mantis-auctioneer-api = { path = "mantis-auctioneer-api" } mantis-sdk = { path = "mantis-sdk" } - -[net] -git-fetch-with-cli = true diff --git a/mantis-auctioneer-api/Cargo.toml b/mantis-auctioneer-api/Cargo.toml new file mode 100644 index 0000000..e11762a --- /dev/null +++ b/mantis-auctioneer-api/Cargo.toml @@ -0,0 +1,27 @@ +[package] +edition = "2021" +name = "mantis-auctioneer-api" +version = "0.1.0" + +[lib] +name = "auctioneer_api" +path = "src/lib.rs" + +[dependencies] +alloy = { workspace = true, features = ["signers", "signer-local"] } +anyhow = { workspace = true } +axum = { workspace = true, features = ["ws", "macros"] } +num = { workspace = true, features = ["serde"] } +regex = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true, features = ["raw_value"] } +solana-sdk = { workspace = true } +strum = { workspace = true } +strum_macros = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +utoipa = { workspace = true, features = ["axum_extras"] } +utoipa-swagger-ui = { workspace = true, features = ["axum"] } +uuid = { workspace = true, features = ["serde"] } +validator = { workspace = true, features = ["derive"] } +zip = { workspace = true } diff --git a/mantis-auctioneer-api/src/http.rs b/mantis-auctioneer-api/src/http.rs new file mode 100644 index 0000000..693622c --- /dev/null +++ b/mantis-auctioneer-api/src/http.rs @@ -0,0 +1,337 @@ +use std::str::FromStr; + +use alloy::hex::FromHexError; +use alloy::primitives::Address; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::{BoxError, Json}; +use num::bigint::ParseBigIntError; +use num::BigUint; +use serde::{Deserialize, Serialize}; +use solana_sdk::pubkey::{ParsePubkeyError, Pubkey}; +use strum::EnumString; +use tracing::error; +use utoipa::ToSchema; +use validator::{Validate, ValidationError, ValidationErrors}; + +use crate::{biguint, IntentChain}; + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct CheckHealthResponse { + pub status: String, +} + +#[derive(Debug, Serialize, Deserialize, Validate, ToSchema)] +pub struct ListQuotesQuery { + pub src_chain: IntentChain, + pub dst_chain: IntentChain, + #[validate(custom(function = "validate_token_in"))] + pub token_in: String, + #[schema(value_type = String)] + #[serde(with = "biguint")] + #[validate(custom(function = "validate_token_in_amount"))] + pub token_in_amount: BigUint, + #[validate(custom(function = "validate_token_out"))] + pub token_out: String, +} + +fn validate_token_in_amount(token_in_amount: &BigUint) -> Result<(), ValidationError> { + if *token_in_amount == BigUint::ZERO { + return Err(ValidationError::new("token_in_amount cannot be 0")); + } + Ok(()) +} + +fn validate_token_in(token_in: &str) -> Result<(), ValidationError> { + let address_result = Address::from_str(token_in); + let pubkey_result = Pubkey::from_str(token_in); + + if address_result.is_err() && pubkey_result.is_err() { + return Err(ValidationError::new("token_in is not a valid token address")) + } + Ok(()) +} + +fn validate_token_out(token_out: &str) -> Result<(), ValidationError> { + let address_result = Address::from_str(token_out); + let pubkey_result = Pubkey::from_str(token_out); + + if address_result.is_err() && pubkey_result.is_err() { + return Err(ValidationError::new("token_out is not a valid token address")) + } + Ok(()) +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct ListQuotesResponse { + pub src_chain: String, + pub dst_chain: String, + pub solver_quotes: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Validate, ToSchema)] +pub struct ListFeesQuery { + #[validate(custom(function = "validate_authority"))] + pub authority: String, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct ListFeesResponse { + pub solana: Vec, + pub ethereum: Vec, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, ToSchema)] +pub struct SolanaTokenFee { + pub token: String, + pub symbol: Option, + pub balance: String, + pub value: String, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, ToSchema)] +pub struct EthereumTokenFee { + pub token: String, + pub symbol: Option, + pub balance: String, + pub value: String, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct ListSwapIntentsQuery { + pub src_chain: Option, + pub period: Option, + #[serde(default)] + pub src_user: Vec, + pub page: Option, + pub page_size: Option, +} + +#[derive(Debug, Clone, Copy, EnumString, Serialize, Deserialize, PartialEq, Eq, ToSchema)] +#[strum(serialize_all = "lowercase")] +#[serde(rename_all = "snake_case")] +pub enum Period { + All, + OneDay, + OneWeek, + OneMonth, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct ListSwapIntentsResponse { + pub page: u16, + pub items: u16, + pub page_size: u16, + pub page_max: u64, + pub items_max: u64, + pub intents: Vec, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct GetSwapIntentResponse { + pub intent: SwapIntent, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, ToSchema)] +pub struct SwapIntent { + pub intent_id: u64, + pub created_at: String, + pub escrow_transaction: String, + pub src_user: String, + pub dst_user: String, + pub src_chain: String, + pub dst_chain: String, + pub token_in: String, + pub amount_in: String, + pub token_out: String, + pub amount_wanted: String, + pub amount_provided: Option, + pub fee_amount: String, + pub timeout_sec: u64, + pub is_canceled: bool, + pub is_solved: bool, + pub ai_agent: bool, + pub solver: Option, + pub canceled_at: Option, + pub solved_at: Option, + pub solve_transaction: Option, + pub token_in_price_usd: Option, + pub token_out_price_usd: Option, + pub token_in_symbol: Option, + pub token_out_symbol: Option, + pub token_in_decimals: Option, + pub token_out_decimals: Option, +} + +#[derive(Debug, Serialize, Deserialize, Validate, ToSchema)] +pub struct RescanQuery { + #[validate(custom(function = "validate_authority"))] + pub authority: String, + pub src_chain: IntentChain, + pub start: u64, + pub end: u64, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct RescanResponse {} + +#[derive(Debug, Serialize, Deserialize, Validate, ToSchema)] +pub struct UnlockQuery { + #[validate(custom(function = "validate_authority"))] + pub authority: String, + #[validate(custom(function = "validate_intent_id"))] + pub intent_id: u64, + pub src_chain: IntentChain, + pub token_out: String, + pub amount_out: String, + pub dst_user: String, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct UnlockResponse { + pub transaction: String, +} + +#[derive(Debug, Serialize, Deserialize, Validate, ToSchema)] +pub struct CancelQuery { + #[validate(custom(function = "validate_authority"))] + pub authority: String, + #[validate(custom(function = "validate_intent_id"))] + pub intent_id: u64, + pub src_chain: IntentChain, + pub token_in_mint: Option, + pub src_user: Option, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct CancelResponse { + pub transaction: String, +} + +fn validate_authority(authority: &str) -> Result<(), ValidationError> { + if authority != "4e6d9d0849740b385d60c59fded9ee97" { + return Err(ValidationError::new("Invalid authority")); + } + Ok(()) +} + +pub fn validate_intent_id(intent_id: u64) -> Result<(), ValidationError> { + if !(100_000_000_000..=999_999_999_999).contains(&intent_id) { + return Err(ValidationError::new("intent_id is not a 12-digit number")); + } + Ok(()) +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct GetStatsQuery { + pub period: Option, + pub src_chain: Option, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct GetStatsResponse { + pub total_trades: u64, + pub unique_addresses: u64, + pub total_volume: f64, + pub total_local_volume: f64, + pub total_remote_volume: f64, + pub total_value_in: f64, + pub total_value_out: f64, + pub total_fees: f64, + pub top_assets: Vec, + pub top_solvers: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct StatsSolver { + pub address: String, + pub volume: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct StatsAsset { + pub address: String, + pub symbol: Option, + pub volume: f64, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct GetTimeSeriesQuery { + pub period: Option, + pub src_chain: Option, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct GetTimeSeriesResponse { + pub start_timestamp: u64, + pub end_timestamp: u64, + pub total_trades: Vec, + pub total_volume: Vec, + pub total_value_in: Vec, + pub total_value_out: Vec, + pub total_fees: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct SolverQuote { + pub solver_id: String, + pub token_in: String, + pub token_in_amount: String, + pub token_out: String, + pub token_out_amount: String, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct ServerErrorResponse { + pub code: u16, + pub message: String, +} + +#[derive(thiserror::Error, Debug)] +pub enum ServerError { + #[error("Validation error: {0}")] + Validation(#[from] ValidationErrors), + #[error("Parse big int error: {0}")] + ParseBigInt(#[from] ParseBigIntError), + #[error("Parse pubkey error: {0}")] + ParsePubkey(#[from] ParsePubkeyError), + #[error("Parse error: {0}")] + ParseStrum(#[from] strum::ParseError), + #[error("Parse address error: {0}")] + ParseAddressError(#[from] FromHexError), + #[error("Not found: {0}")] + NotFound(BoxError), + #[error("Request timeout: {0}")] + Timeout(BoxError), + #[error("Rate limit reached: {0}")] + Ratelimit(BoxError), + #[error("Internal error: {0}")] + InternalBoxed(#[from] BoxError), + #[error("Internal error: {0}")] + InternalAnyhow(#[from] anyhow::Error), +} + +impl IntoResponse for ServerError { + fn into_response(self) -> Response { + error!("Server error: {:#}", &self); + let (status, error_message) = match self { + Self::Validation(error) => (StatusCode::BAD_REQUEST, error.to_string()), + Self::ParseBigInt(error) => (StatusCode::BAD_REQUEST, error.to_string()), + Self::ParsePubkey(error) => (StatusCode::BAD_REQUEST, error.to_string()), + Self::ParseStrum(error) => (StatusCode::BAD_REQUEST, error.to_string()), + Self::ParseAddressError(error) => (StatusCode::BAD_REQUEST, error.to_string()), + Self::NotFound(error) => (StatusCode::BAD_REQUEST, error.to_string()), + Self::Timeout(error) => (StatusCode::REQUEST_TIMEOUT, error.to_string()), + Self::Ratelimit(error) => (StatusCode::TOO_MANY_REQUESTS, error.to_string()), + Self::InternalBoxed(error) => (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()), + Self::InternalAnyhow(error) => (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()), + }; + + let body = Json(ServerErrorResponse { + code: status.as_u16(), + message: error_message, + }); + + (status, body).into_response() + } +} diff --git a/mantis-auctioneer-api/src/lib.rs b/mantis-auctioneer-api/src/lib.rs new file mode 100644 index 0000000..8ae3b31 --- /dev/null +++ b/mantis-auctioneer-api/src/lib.rs @@ -0,0 +1,57 @@ +pub mod http; +pub mod ws; + +use anyhow::{Context, Error}; +use num::{BigUint, Num}; +use serde::{Deserialize, Serialize}; +use strum::{Display, EnumString, FromRepr}; +use utoipa::ToSchema; +pub use validator::Validate; + +pub const API_VERSION: &str = "v1-beta"; + +#[derive( + Debug, Display, FromRepr, Clone, Copy, EnumString, Serialize, Deserialize, PartialEq, Eq, ToSchema, +)] +#[strum(serialize_all = "lowercase")] +#[serde(rename_all = "lowercase")] +#[repr(u8)] +pub enum IntentChain { + Ethereum = 1, + Solana = 2, + Base = 3, +} + +impl From for u8 { + fn from(chain: IntentChain) -> Self { + chain as u8 + } +} + +impl TryFrom for IntentChain { + type Error = Error; + + fn try_from(id: u8) -> Result { + IntentChain::from_repr(id).context("invalid intent chain id") + } +} + +/// Custom serialization module for the BigUint type from/to a decimal string. +pub mod biguint { + use super::*; + + pub fn serialize(n: &BigUint, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&n.to_str_radix(10)) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s: String = Deserialize::deserialize(deserializer)?; + BigUint::from_str_radix(&s, 10).map_err(serde::de::Error::custom) + } +} diff --git a/mantis-auctioneer-api/src/ws.rs b/mantis-auctioneer-api/src/ws.rs new file mode 100644 index 0000000..f15d92e --- /dev/null +++ b/mantis-auctioneer-api/src/ws.rs @@ -0,0 +1,352 @@ +use std::str::FromStr; + +use alloy::hex; +use alloy::primitives::{keccak256, Address, FixedBytes}; +use alloy::signers::{Signature, SignerSync}; +use anyhow::{anyhow, Error, Result}; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use solana_sdk::pubkey::Pubkey; +use uuid::Uuid; +use validator::{Validate, ValidationError}; + +use crate::IntentChain; + +pub trait SignableMessage { + fn signature(&self) -> &Option; + + fn signature_mut(&mut self) -> &mut Option; + + fn hash(&self) -> Result>; + + fn signed(mut self, signer: S) -> Result + where + Self: Sized, + { + let hash = self.hash()?; + let signature = self.signature_mut(); + *signature = Some(hex::encode(signer.sign_hash_sync(&hash)?.as_bytes())); + Ok(self) + } + + fn verify(&self, expected_address: Address) -> Result<()> { + if let Some(signature) = self.signature() { + let hash = self.hash()?; + let signature = Signature::from_str(signature)?; + let recovered_address = signature.recover_address_from_prehash(&hash)?; + if expected_address != recovered_address { + return Err(anyhow!("Recovered address does not match the expected address")); + } + } + Ok(()) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum ServerMessage { + AuctionStart(ServerAuctionStartMessage), + AuctionResult(ServerAuctionResultMessage), + Quote(ServerQuoteMessage), + UnlockedFunds(ServerUnlockedFundsMessage), + Error(ServerErrorMessage), +} + +impl ServerMessage { + pub fn to_json(&self) -> Result { + serde_json::to_string(self).map_err(Error::from) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum ClientMessage { + Register(ClientRegisterMessage), + Bid(ClientBidMessage), + Solve(ClientSolveMessage), + Quote(ClientQuoteMessage), +} + +impl ClientMessage { + pub fn to_json(&self) -> Result { + serde_json::to_string(self).map_err(Error::from) + } +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct ServerAuctionStartMessage { + pub intent_id: u64, + pub intent: SwapIntent, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct SwapIntent { + pub src_chain: IntentChain, + pub dst_chain: IntentChain, + pub src_user: String, + pub dst_user: String, + pub token_in: String, + pub amount_in: String, + pub token_out: String, + pub amount_out: String, + pub timeout: u64, +} + +impl SwapIntent { + pub fn is_single_domain(&self) -> bool { + self.src_chain == self.dst_chain + } +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct ServerAuctionResultMessage { + pub won: bool, + pub intent_id: u64, + pub amount: String, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct ServerQuoteMessage { + pub request_id: Uuid, + pub intent: SwapIntent, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct ServerUnlockedFundsMessage { + pub src_chain_id: u8, + pub solver: String, + pub intent_id: u64, + pub token_in: String, + pub amount_in: String, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct ServerErrorMessage { + pub code: u16, + pub message: String, + pub request_id: Option, +} + +impl ServerErrorMessage { + pub fn new(code: u16, message: String, request_id: Option) -> Self { + ServerErrorMessage { + code, + message, + request_id, + } + } + + pub fn invalid_message(request_id: Option) -> Self { + ServerErrorMessage { + code: 400, + message: "Invalid message".to_string(), + request_id, + } + } + + pub fn validation_failure(request_id: Option) -> Self { + ServerErrorMessage { + code: 400, + message: "Message validation failed".to_string(), + request_id, + } + } + + pub fn invalid_signature(request_id: Option) -> Self { + ServerErrorMessage { + code: 400, + message: "Invalid message signature".to_string(), + request_id, + } + } + + pub fn missing_field(field: String, request_id: Option) -> Self { + ServerErrorMessage { + code: 400, + message: format!("Missing {} message field", field), + request_id, + } + } + + pub fn unregistered_solver(solver_id: String, request_id: Option) -> Self { + ServerErrorMessage { + code: 403, + message: format!("Solver {} is not registered", solver_id), + request_id, + } + } + + pub fn bannded_solver(solver_id: String, request_id: Option) -> Self { + ServerErrorMessage { + code: 403, + message: format!("Solver {} is banned", solver_id), + request_id, + } + } +} + +#[derive(Serialize, Deserialize, Clone, Debug, Validate)] +pub struct ClientRegisterMessage { + #[validate(length(min = 5, max = 15), custom(function = "validate_alphanumeric"))] + pub solver_id: String, + pub solver_addresses: SolverAddresses, + pub signature: Option, +} + +#[derive(Serialize, Deserialize, Clone, Debug, Default)] +pub struct SolverAddresses { + pub ethereum: Address, + pub solana: Pubkey, + pub base: Address, +} + +fn validate_alphanumeric(solver_id: &str) -> Result<(), ValidationError> { + let alphanumeric = Regex::new(r"^[a-zA-Z0-9]+$").expect("could not compile regex"); + if !alphanumeric.is_match(solver_id) { + return Err(ValidationError::new("not alphanumeric")); + } + Ok(()) +} + +impl ClientRegisterMessage { + pub fn new(solver_id: String, solver_addresses: SolverAddresses) -> Self { + ClientRegisterMessage { + solver_id, + solver_addresses, + signature: None, + } + } +} + +impl SignableMessage for ClientRegisterMessage { + fn hash(&self) -> Result> { + let message = Self { + signature: None, + ..self.clone() + }; + + Ok(keccak256(serde_json::to_string(&message)?)) + } + + fn signature(&self) -> &Option { + &self.signature + } + + fn signature_mut(&mut self) -> &mut Option { + &mut self.signature + } +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct ClientBidMessage { + pub solver_id: String, + pub intent_id: u64, + pub amount: String, + pub signature: Option, +} + +impl ClientBidMessage { + pub fn new(solver_id: String, intent_id: u64, amount: String) -> Self { + ClientBidMessage { + solver_id, + intent_id, + amount, + signature: None, + } + } +} + +impl SignableMessage for ClientBidMessage { + fn hash(&self) -> Result> { + let message = Self { + signature: None, + ..self.clone() + }; + + Ok(keccak256(serde_json::to_string(&message)?)) + } + + fn signature(&self) -> &Option { + &self.signature + } + + fn signature_mut(&mut self) -> &mut Option { + &mut self.signature + } +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct ClientSolveMessage { + pub solver_id: String, + pub intent_id: u64, + pub solve_transaction: String, + pub signature: Option, +} + +impl ClientSolveMessage { + pub fn new(solver_id: String, intent_id: u64, solve_transaction: String) -> Self { + ClientSolveMessage { + solver_id, + intent_id, + solve_transaction, + signature: None, + } + } +} + +impl SignableMessage for ClientSolveMessage { + fn hash(&self) -> Result> { + let message = Self { + signature: None, + ..self.clone() + }; + + Ok(keccak256(serde_json::to_string(&message)?)) + } + + fn signature(&self) -> &Option { + &self.signature + } + + fn signature_mut(&mut self) -> &mut Option { + &mut self.signature + } +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct ClientQuoteMessage { + pub request_id: Uuid, + pub src_chain: String, + pub dst_chain: String, + pub solver_id: String, + pub token_in: String, + pub amount_in: String, + pub token_out: String, + pub amount_out: String, +} + +#[cfg(test)] +mod tests { + use alloy::signers::local::PrivateKeySigner; + + use super::*; + + #[test] + fn test_verify_signature() -> Result<()> { + let private_key = "2533129b71c9e08d2a1174ac943dfaf699d5d148debe38ef5192db7e84efcf1c"; + let signer = PrivateKeySigner::from_str(private_key)?; + let expected_address = signer.address(); + let message = ClientRegisterMessage::new( + "123456".into(), + SolverAddresses { + ethereum: signer.address(), + solana: Pubkey::from_str_const("5zCZ3jk8EZnJyG7fhDqD6tmqiYTLZjik5HUpGMnHrZfC"), + base: signer.address(), + }, + ); + + let signed = message.signed(signer)?; + + signed.verify(expected_address)?; + + Ok(()) + } +} diff --git a/mantis-sdk/Cargo.toml b/mantis-sdk/Cargo.toml index c750efb..064735e 100644 --- a/mantis-sdk/Cargo.toml +++ b/mantis-sdk/Cargo.toml @@ -42,4 +42,4 @@ tracing = { workspace = true } tracing-subscriber = { workspace = true } url = { workspace = true } uuid = { workspace = true } -auctioneer-api = { workspace = true } +mantis-auctioneer-api = { workspace = true } From 1dcb0eb066920e3bfaf0f3a90dc32c27f8792cf8 Mon Sep 17 00:00:00 2001 From: CryptCortex Date: Wed, 14 May 2025 12:33:24 +0100 Subject: [PATCH 03/14] feat: basic implementation of auction websocket client --- Cargo.lock | 10 +- Cargo.toml | 6 +- README.md | 2 +- mantis-sdk/Cargo.toml | 3 +- mantis-sdk/src/auction/mod.rs | 7 +- mantis-sdk/src/auction/ws.rs | 515 ++++++++++++++++++++++++++++++++++ 6 files changed, 532 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d6f9fa8..f4698b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4921,7 +4921,8 @@ dependencies = [ "strum_macros 0.26.4", "thiserror 2.0.12", "tokio", - "tokio-tungstenite 0.26.2", + "tokio-tungstenite 0.20.1", + "tokio-util 0.7.15", "tracing", "tracing-subscriber", "url 2.5.4", @@ -11953,9 +11954,9 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.19.1" +version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7437ac7763b9b123ccf33c338a5cc1bac6f69b45a136c19bdd8a65e3916435bf" +checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" dependencies = [ "fastrand", "getrandom 0.3.3", @@ -12267,9 +12268,7 @@ checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" dependencies = [ "futures-util", "log", - "native-tls", "tokio", - "tokio-native-tls", "tungstenite 0.26.2", ] @@ -12566,7 +12565,6 @@ dependencies = [ "http 1.3.1", "httparse", "log", - "native-tls", "rand 0.9.1", "sha1", "thiserror 2.0.12", diff --git a/Cargo.toml b/Cargo.toml index fb341d4..414af4f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,7 +49,9 @@ strum = { version = "0.26.2", features = ["derive"] } strum_macros = "0.26.4" thiserror = "2.0.12" tokio = { version = "1.43.0", features = ["full"] } -tokio-tungstenite = { version = "0.26.2", features = ["native-tls"] } +tokio-tungstenite = "0.20.1" +tokio-util = { version = "0.7" } +# tokio-tungstenite = "0.26.2" tracing = "0.1.41" tracing-subscriber = "0.3.19" url = "2.5.4" @@ -59,5 +61,5 @@ uuid = { version = "1.16.0", features = ["v4"] } validator = "0.19.0" zip = "~2.4.2" -mantis-auctioneer-api = { path = "mantis-auctioneer-api" } +auctioneer-api = { path = "mantis-auctioneer-api", package = "mantis-auctioneer-api" } mantis-sdk = { path = "mantis-sdk" } diff --git a/README.md b/README.md index 04a23b6..0fb663f 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ The `mantis_sdk::ethereum` module abstracts the Ethereum smart contract interact The `mantis_sdk::solana` module abstracts the Solana Anchor program interactions and provides various utility functions. -The `mantis_sdk::auction` module provides a way for solvers to integrate with the intent auction process by communicating with the auctioneer API. +The `mantis_sdk::auction` module provides a way for solvers to integrate with the intent auction process by communicating with the auctioneer API via a robust WebSocket client: # Mantis SDK 🔷 diff --git a/mantis-sdk/Cargo.toml b/mantis-sdk/Cargo.toml index 064735e..2e06387 100644 --- a/mantis-sdk/Cargo.toml +++ b/mantis-sdk/Cargo.toml @@ -38,8 +38,9 @@ strum_macros = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } tokio-tungstenite = { workspace = true } +tokio-util = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } url = { workspace = true } uuid = { workspace = true } -mantis-auctioneer-api = { workspace = true } +auctioneer-api = { workspace = true } diff --git a/mantis-sdk/src/auction/mod.rs b/mantis-sdk/src/auction/mod.rs index f2066e6..cc8652a 100644 --- a/mantis-sdk/src/auction/mod.rs +++ b/mantis-sdk/src/auction/mod.rs @@ -1,2 +1,7 @@ -pub mod http; pub mod ws; + +// Re-export the client struct for easier access +pub use ws::AuctioneerWsClient; + +// Re-export the auctioneer_api types for easier use +pub use auctioneer_api::IntentChain; \ No newline at end of file diff --git a/mantis-sdk/src/auction/ws.rs b/mantis-sdk/src/auction/ws.rs index 8b13789..30646e5 100644 --- a/mantis-sdk/src/auction/ws.rs +++ b/mantis-sdk/src/auction/ws.rs @@ -1 +1,516 @@ +use anyhow::{anyhow, Context, Result}; +use auctioneer_api::ws::{ClientMessage, ClientRegisterMessage, ServerMessage}; +use futures::{stream::SplitSink, SinkExt, StreamExt}; +use std::collections::VecDeque; +use std::sync::Arc; +use std::time::Duration; +use tokio::net::TcpStream; +use tokio::sync::{mpsc, Mutex, RwLock}; +use tokio::time::sleep; +use tokio_tungstenite::{connect_async, tungstenite::protocol::Message, MaybeTlsStream, WebSocketStream}; +use tokio_util::sync::CancellationToken; +use tracing::{debug, error, info, warn}; +use url::Url; +/// Connection state of the WebSocket client +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConnectionState { + /// Client is connected to the server + Connected, + /// Client is disconnected and trying to reconnect + Reconnecting, + /// Client is disconnected and not trying to reconnect + Disconnected, + /// Client is shutting down + ShuttingDown, +} + +/// Configuration for the WebSocket client +#[derive(Debug, Clone)] +pub struct ClientConfig { + /// Maximum number of reconnection attempts (0 for indefinite) + pub max_reconnect_attempts: u32, + /// Base delay between reconnection attempts (grows exponentially) + pub reconnect_base_delay: Duration, + /// Maximum delay between reconnection attempts + pub reconnect_max_delay: Duration, + /// Size of the message channels + pub channel_size: usize, + /// Ping interval to keep the connection alive + pub ping_interval: Duration, + /// Timeout for connection attempts + pub connection_timeout: Duration, +} + +impl Default for ClientConfig { + fn default() -> Self { + Self { + max_reconnect_attempts: 5, // Set to 0 for indefinite retries + reconnect_base_delay: Duration::from_secs(1), + reconnect_max_delay: Duration::from_secs(60), + channel_size: 100, + ping_interval: Duration::from_secs(30), + connection_timeout: Duration::from_secs(10), + } + } +} + +type WsWriter = Arc>, Message>>>; + +/// WebSocket client for communicating with the auctioneer service +/// with support for automatic reconnection and error handling +pub struct AuctioneerWsClient { + /// URL of the auctioneer WebSocket service + url: Url, + /// Sender half of the channel used to send messages to the connection_manager task + tx_to_manager: mpsc::Sender, + /// Channel receiver for incoming server messages from the connection_manager task + rx_from_manager: Arc>>, + /// Client configuration + config: ClientConfig, + /// Current connection state + state: Arc>, + /// Outgoing message buffer that couldn't be sent due to disconnection + outgoing_buffer: Arc>>, + /// Most recent registration message for reconnection + last_registration: Arc>>, + /// Token to signal shutdown to the connection_manager task + shutdown_token: CancellationToken, +} + +impl AuctioneerWsClient { + pub async fn connect(url_str: &str, config: Option) -> Result { + let url = Url::parse(url_str).context("Invalid WebSocket URL")?; + let config = config.unwrap_or_default(); + + let (api_tx, manager_rx_client_msg) = mpsc::channel::(config.channel_size); + let (manager_tx_server_msg, api_rx) = mpsc::channel::(config.channel_size); + + let state = Arc::new(RwLock::new(ConnectionState::Disconnected)); + let outgoing_buffer = Arc::new(Mutex::new(Vec::new())); + let last_registration = Arc::new(Mutex::new(None)); + let shutdown_token = CancellationToken::new(); + + let manager_url = url.clone(); + let manager_config = config.clone(); + let manager_state = state.clone(); + let manager_outgoing_buffer = outgoing_buffer.clone(); + let manager_last_registration = last_registration.clone(); + let manager_shutdown_token = shutdown_token.clone(); + + // Spawn the connection manager task + tokio::spawn(async move { + connection_manager( + manager_url, + manager_config, + manager_state, + manager_rx_client_msg, + manager_tx_server_msg, + manager_outgoing_buffer, + manager_last_registration, + manager_shutdown_token, + ) + .await; + }); + + Ok(Self { + url, + tx_to_manager: api_tx, + rx_from_manager: Arc::new(Mutex::new(api_rx)), + config, + state, + outgoing_buffer, + last_registration, + shutdown_token, + }) + } + + /// Send a client message to the auctioneer service + pub async fn send_message(&self, message: ClientMessage) -> Result<()> { + if let ClientMessage::Register(ref reg_msg) = message { + *self.last_registration.lock().await = Some(reg_msg.clone()); + } + + let current_state = *self.state.read().await; + match current_state { + ConnectionState::Connected => { + self.tx_to_manager + .send(message) + .await + .map_err(|_| anyhow!("Failed to send message: Connection manager task closed"))?; + } + ConnectionState::Reconnecting | ConnectionState::Disconnected => { + debug!("Connection not ready ({:?}), buffering message", current_state); + self.outgoing_buffer.lock().await.push(message); + if current_state == ConnectionState::Disconnected { + // Optionally, trigger a reconnect attempt if fully disconnected and not already trying + // This depends on desired behavior; currently manager task handles retries. + warn!("Message buffered while client is fully disconnected. It will be sent upon reconnection."); + } + } + ConnectionState::ShuttingDown => { + return Err(anyhow!("Client is shutting down, cannot send message.")); + } + } + Ok(()) + } + + /// Receive a server message from the auctioneer service + pub async fn receive_message(&self) -> Result { + let mut rx_guard = self.rx_from_manager.lock().await; + rx_guard + .recv() + .await + .ok_or_else(|| anyhow!("WebSocket connection closed and manager task terminated")) + } + + /// Try to receive a server message from the auctioneer service + pub async fn try_receive_message(&self) -> Result> { + let mut rx_guard = self.rx_from_manager.lock().await; + match rx_guard.try_recv() { + Ok(msg) => Ok(Some(msg)), + Err(mpsc::error::TryRecvError::Empty) => Ok(None), + Err(mpsc::error::TryRecvError::Disconnected) => { + Err(anyhow!("WebSocket connection closed and manager task terminated")) + } + } + } + + // Helper methods + pub async fn register(&self, register_message: ClientRegisterMessage) -> Result<()> { + self.send_message(ClientMessage::Register(register_message)).await + } + pub async fn bid(&self, bid_message: auctioneer_api::ws::ClientBidMessage) -> Result<()> { + self.send_message(ClientMessage::Bid(bid_message)).await + } + pub async fn solve(&self, solve_message: auctioneer_api::ws::ClientSolveMessage) -> Result<()> { + self.send_message(ClientMessage::Solve(solve_message)).await + } + pub async fn quote(&self, quote_message: auctioneer_api::ws::ClientQuoteMessage) -> Result<()> { + self.send_message(ClientMessage::Quote(quote_message)).await + } + + pub async fn connection_state(&self) -> ConnectionState { + *self.state.read().await + } + + pub fn url(&self) -> &Url { + &self.url + } + + pub fn config(&self) -> &ClientConfig { + &self.config + } + + pub async fn wait_for_connection(&self, timeout: Duration) -> Result<()> { + let start = std::time::Instant::now(); + loop { + if *self.state.read().await == ConnectionState::Connected { + return Ok(()); + } + if start.elapsed() >= timeout { + return Err(anyhow!("Timed out waiting for connection")); + } + if self.shutdown_token.is_cancelled() { + return Err(anyhow!("Client is shutting down while waiting for connection")); + } + sleep(Duration::from_millis(100)).await; + } + } +} + +impl Drop for AuctioneerWsClient { + fn drop(&mut self) { + info!("AuctioneerWsClient is being dropped, signalling shutdown."); + self.shutdown_token.cancel(); + // The connection_manager task will handle graceful shutdown of the WebSocket. + } +} + +async fn connection_manager( + url: Url, + config: ClientConfig, + state: Arc>, + mut client_msg_rx: mpsc::Receiver, // Receives messages from API via self.tx_to_manager + server_msg_tx: mpsc::Sender, // Sends messages to API via self.rx_from_manager + outgoing_buffer: Arc>>, + last_registration: Arc>>, + shutdown_token: CancellationToken, +) { + let mut attempts = 0; + + 'reconnect_loop: loop { + if shutdown_token.is_cancelled() { + info!("Shutdown signalled, connection manager exiting."); + *state.write().await = ConnectionState::ShuttingDown; + break; + } + + if attempts > 0 { + // Max attempts check (0 means infinite) + if config.max_reconnect_attempts > 0 && attempts >= config.max_reconnect_attempts { + error!( + "Max reconnection attempts ({}) reached. Giving up.", + config.max_reconnect_attempts + ); + *state.write().await = ConnectionState::Disconnected; + break; // Exit manager task + } + + let delay_pow = attempts.saturating_sub(1); // First retry (attempts=1) has delay_pow=0 + let delay = std::cmp::min( + config.reconnect_base_delay * 2u32.pow(delay_pow), + config.reconnect_max_delay, + ); + warn!( + "Attempting to reconnect in {:?} (attempt {}/{})", + delay, + attempts, + if config.max_reconnect_attempts == 0 { + "infinite".to_string() + } else { + config.max_reconnect_attempts.to_string() + } + ); + + tokio::select! { + _ = sleep(delay) => {}, + _ = shutdown_token.cancelled() => { + info!("Shutdown signalled during backoff, connection manager exiting."); + *state.write().await = ConnectionState::ShuttingDown; + return; + } + } + } + + *state.write().await = ConnectionState::Reconnecting; + info!("Attempting to connect to {}...", url); + + let connect_future = connect_async(&url); + let ws_stream_result = tokio::time::timeout(config.connection_timeout, connect_future).await; + + let ws_stream = match ws_stream_result { + Ok(Ok((stream, _response))) => { + info!("Successfully connected to {}", url); + *state.write().await = ConnectionState::Connected; + attempts = 0; // Reset attempts on successful connection + stream + } + Ok(Err(err)) => { + error!("Failed to connect to {}: {}", url, err); + attempts += 1; + continue; // Retry connection + } + Err(_) => { + error!( + "Connection to {} timed out after {:?}", + url, config.connection_timeout + ); + attempts += 1; + continue; // Retry connection + } + }; + + let (ws_writer_raw, mut ws_reader) = ws_stream.split(); + let ws_writer: WsWriter = Arc::new(Mutex::new(ws_writer_raw)); + + // Send buffered messages + { + let mut buffer_guard = outgoing_buffer.lock().await; + if !buffer_guard.is_empty() { + info!("Sending {} buffered messages...", buffer_guard.len()); + // Drain messages into a temporary vector to release the lock sooner. + let mut messages_to_process: VecDeque<_> = buffer_guard.drain(..).collect(); + drop(buffer_guard); + + while let Some(msg) = messages_to_process.pop_front() { + // Process one by one + if shutdown_token.is_cancelled() { + // If shutdown, put this message and the rest back into the shared buffer + let mut re_buffer_guard = outgoing_buffer.lock().await; + re_buffer_guard.push(msg); + re_buffer_guard.extend(messages_to_process); // The rest + info!("Shutdown during buffered send. Re-buffered remaining messages."); + break; // Stop processing + } + + match serde_json::to_string(&msg) { + Ok(json_msg) => { + let mut writer_guard = ws_writer.lock().await; + if let Err(e) = writer_guard.send(Message::Text(json_msg)).await { + error!("Failed to send buffered message: {}. Re-buffering this and subsequent messages.", e); + + // Put the failed message and the rest of messages_to_process back + { + let mut re_buffer_guard = outgoing_buffer.lock().await; + re_buffer_guard.push(msg); + re_buffer_guard.extend(messages_to_process); + } + + *state.write().await = ConnectionState::Reconnecting; + attempts += 1; + if let Err(e) = + tokio::time::timeout(Duration::from_secs(1), writer_guard.close()).await + { + error!("Timeout while closing WebSocket writer: {}", e); + } + break; // Stop processing, will trigger reconnect from outer loop + } + } + Err(e) => { + warn!("Failed to serialize buffered message: {}. Message dropped.", e); + // This specific message (msg) is lost. The loop continues with the next from messages_to_process. + } + } + } + + if *state.read().await == ConnectionState::Reconnecting { + continue; // If sending buffered failed, reconnect + } + } + } + + // Check after potentially long buffered send + if shutdown_token.is_cancelled() { + continue; + } + + // Re-register if needed + if let Some(reg_msg) = last_registration.lock().await.clone() { + info!("Re-registering with server..."); + match serde_json::to_string(&ClientMessage::Register(reg_msg)) { + Ok(json_msg) => { + if let Err(e) = ws_writer.lock().await.send(Message::Text(json_msg)).await { + error!( + "Failed to send re-registration message: {}. Will retry on next connection.", + e + ); + *state.write().await = ConnectionState::Reconnecting; + attempts += 1; + continue; // Trigger reconnect + } + } + Err(e) => error!("Failed to serialize re-registration message: {}", e), + } + } + + if shutdown_token.is_cancelled() { + continue; + } + + let mut ping_ticker = tokio::time::interval(config.ping_interval); + + // Main message loop for this connection + loop { + tokio::select! { + _ = shutdown_token.cancelled() => { + info!("Shutdown signalled. Closing WebSocket connection."); + *state.write().await = ConnectionState::ShuttingDown; + let _ = ws_writer.lock().await.send(Message::Close(None)).await; // Try to send Close frame + let _ = ws_writer.lock().await.close().await; + break 'reconnect_loop; // Exit manager task completely + } + + Some(client_msg) = client_msg_rx.recv() => { + match serde_json::to_string(&client_msg) { + Ok(json_msg) => { + if let Err(e) = ws_writer.lock().await.send(Message::Text(json_msg)).await { + error!("Failed to send client message: {}. Buffering and attempting reconnect.", e); + outgoing_buffer.lock().await.push(client_msg); + *state.write().await = ConnectionState::Reconnecting; + attempts +=1; + break; // Break select loop, trigger reconnect + } + } + Err(e) => { + error!("Failed to serialize client message: {}", e); + } + } + } + + Some(ws_result) = ws_reader.next() => { + match ws_result { + Ok(ws_msg) => match ws_msg { + Message::Text(text) => { + match serde_json::from_str::(&text) { + Ok(server_msg) => { + if server_msg_tx.send(server_msg).await.is_err() { + error!("Failed to forward server message to API: API receiver dropped."); + // This implies the client struct was dropped or API rx closed. + // We can consider this a form of shutdown. + *state.write().await = ConnectionState::ShuttingDown; + let _ = ws_writer.lock().await.close().await; + return; // Exit manager task + } + } + Err(e) => { + error!("Failed to deserialize server message: {}", e); + } + } + } + Message::Binary(_) => { + debug!("Received binary message, ignoring."); + } + Message::Ping(data) => { + debug!("Received Ping from server, sending Pong."); + if let Err(e) = ws_writer.lock().await.send(Message::Pong(data)).await { + error!("Failed to send Pong: {}. Attempting reconnect.", e); + *state.write().await = ConnectionState::Reconnecting; + attempts +=1; + break; // Break select loop, trigger reconnect + } + } + Message::Pong(_) => { + debug!("Received Pong from server."); + } + Message::Close(close_frame) => { + info!("WebSocket connection closed by server: {:?}", close_frame); + *state.write().await = ConnectionState::Reconnecting; + attempts +=1; + break; // Break select loop, trigger reconnect + } + Message::Frame(_) => { /* Ignore raw frames */ } + }, + Err(e) => { // WebSocket read error + error!("WebSocket read error: {}. Attempting reconnect.", e); + *state.write().await = ConnectionState::Reconnecting; + attempts +=1; + break; // Break select loop, trigger reconnect + } + } + } + + _ = ping_ticker.tick() => { + if *state.read().await == ConnectionState::Connected { + debug!("Sending WebSocket Ping to keep connection alive."); + if let Err(e) = ws_writer.lock().await.send(Message::Ping(Vec::new())).await { + error!("Failed to send Ping: {}. Attempting reconnect.", e); + *state.write().await = ConnectionState::Reconnecting; + attempts +=1; + break; // Break select loop, trigger reconnect + } + } + } + + else => { // One of the channels closed unexpectedly + info!("A channel closed unexpectedly. Assuming disconnection."); + *state.write().await = ConnectionState::Reconnecting; + attempts +=1; + break; + } + } + } + + // If we break from the select loop due to an error, the outer loop will handle reconnection. + // If shutdown_token caused exit, the manager task returns. + // Close the current connection sink before retrying + let _ = ws_writer.lock().await.close().await; + } + + // Manager task is ending, set final state if not already ShuttingDown + let mut final_state_guard = state.write().await; + if *final_state_guard != ConnectionState::ShuttingDown { + *final_state_guard = ConnectionState::Disconnected; + } + info!("Connection manager has shut down."); +} From 1ee4cf0ffa27a9b2f29034d9cbab2b00cb72baae Mon Sep 17 00:00:00 2001 From: CryptCortex Date: Thu, 15 May 2025 13:40:45 +0100 Subject: [PATCH 04/14] feat: add WebSocket auction tests - Add integration tests for auctioneer WebSocket client - Add mock auctioneer implementation for testing - Add tracing-test dependency for test diagnostics - Make IntentChain public in auctioneer-api --- Cargo.lock | 62 +++- mantis-auctioneer-api/src/ws.rs | 2 +- mantis-sdk/Cargo.toml | 3 + mantis-sdk/tests/auction_ws_test.rs | 243 ++++++++++++ mantis-sdk/tests/mock_auctioneer.rs | 554 ++++++++++++++++++++++++++++ 5 files changed, 857 insertions(+), 7 deletions(-) create mode 100644 mantis-sdk/tests/auction_ws_test.rs create mode 100644 mantis-sdk/tests/mock_auctioneer.rs diff --git a/Cargo.lock b/Cargo.lock index f4698b1..c3e39a6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3531,8 +3531,8 @@ dependencies = [ "aho-corasick", "bstr", "log", - "regex-automata", - "regex-syntax", + "regex-automata 0.4.9", + "regex-syntax 0.8.5", ] [[package]] @@ -4925,10 +4925,20 @@ dependencies = [ "tokio-util 0.7.15", "tracing", "tracing-subscriber", + "tracing-test", "url 2.5.4", "uuid", ] +[[package]] +name = "matchers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +dependencies = [ + "regex-automata 0.1.10", +] + [[package]] name = "matches" version = "0.1.10" @@ -5903,7 +5913,7 @@ dependencies = [ "rand 0.8.5", "rand_chacha 0.3.1", "rand_xorshift", - "regex-syntax", + "regex-syntax 0.8.5", "rusty-fork", "tempfile", "unarray", @@ -6297,8 +6307,17 @@ checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" dependencies = [ "aho-corasick", "memchr", - "regex-automata", - "regex-syntax", + "regex-automata 0.4.9", + "regex-syntax 0.8.5", +] + +[[package]] +name = "regex-automata" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" +dependencies = [ + "regex-syntax 0.6.29", ] [[package]] @@ -6309,9 +6328,15 @@ checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" dependencies = [ "aho-corasick", "memchr", - "regex-syntax", + "regex-syntax 0.8.5", ] +[[package]] +name = "regex-syntax" +version = "0.6.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" + [[package]] name = "regex-syntax" version = "0.8.5" @@ -12482,14 +12507,39 @@ version = "0.3.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" dependencies = [ + "matchers", "nu-ansi-term", + "once_cell", + "regex", "sharded-slab", "smallvec", "thread_local", + "tracing", "tracing-core", "tracing-log", ] +[[package]] +name = "tracing-test" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "557b891436fe0d5e0e363427fc7f217abf9ccd510d5136549847bdcbcd011d68" +dependencies = [ + "tracing-core", + "tracing-subscriber", + "tracing-test-macro", +] + +[[package]] +name = "tracing-test-macro" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04659ddb06c87d233c566112c1c9c5b9e98256d9af50ec3bc9c8327f873a7568" +dependencies = [ + "quote", + "syn 2.0.101", +] + [[package]] name = "trait-set" version = "0.3.0" diff --git a/mantis-auctioneer-api/src/ws.rs b/mantis-auctioneer-api/src/ws.rs index f15d92e..bdf3808 100644 --- a/mantis-auctioneer-api/src/ws.rs +++ b/mantis-auctioneer-api/src/ws.rs @@ -10,7 +10,7 @@ use solana_sdk::pubkey::Pubkey; use uuid::Uuid; use validator::{Validate, ValidationError}; -use crate::IntentChain; +pub use crate::IntentChain; pub trait SignableMessage { fn signature(&self) -> &Option; diff --git a/mantis-sdk/Cargo.toml b/mantis-sdk/Cargo.toml index 2e06387..c280d78 100644 --- a/mantis-sdk/Cargo.toml +++ b/mantis-sdk/Cargo.toml @@ -44,3 +44,6 @@ tracing-subscriber = { workspace = true } url = { workspace = true } uuid = { workspace = true } auctioneer-api = { workspace = true } + +[dev-dependencies] +tracing-test = "0.2.5" diff --git a/mantis-sdk/tests/auction_ws_test.rs b/mantis-sdk/tests/auction_ws_test.rs new file mode 100644 index 0000000..05d3ae9 --- /dev/null +++ b/mantis-sdk/tests/auction_ws_test.rs @@ -0,0 +1,243 @@ +use alloy::primitives::Address; +use auctioneer_api::ws::{ + ClientQuoteMessage, ClientRegisterMessage, IntentChain as ApiIntentChain, ServerAuctionStartMessage, + ServerErrorMessage, ServerMessage, SolverAddresses, SwapIntent, +}; +use mantis_sdk::auction::{ws::ClientConfig as AuctionClientConfig, ws::ConnectionState, AuctioneerWsClient}; +use solana_sdk::pubkey::Pubkey; +use std::str::FromStr; +use std::time::Duration; +use tokio::time::timeout; +use tracing::info; +use tracing_test::traced_test; +use uuid::Uuid; + +mod mock_auctioneer; + +use mock_auctioneer::{ + ClientMessageMatcher, ClientMessageTypeMatcher, MockAuctioneer, MockServerConfig, ScriptedAction, +}; + +#[tokio::test] +#[traced_test] +async fn test_simple_scripted_interaction() { + let server_script = vec![ + ScriptedAction::ExpectClientMessage { + matcher: ClientMessageMatcher::ByType(ClientMessageTypeMatcher::Register), + response: Ok(None), + timeout_duration: Some(Duration::from_secs(5)), + }, + ScriptedAction::SendServerMessage(ServerMessage::AuctionStart(ServerAuctionStartMessage { + intent_id: 1234567890123, + intent: SwapIntent { + src_chain: ApiIntentChain::Ethereum, + dst_chain: ApiIntentChain::Solana, + src_user: "0xTestSourceUser".to_string(), + dst_user: "TestDestinationUserOnSolana".to_string(), + token_in: "0xTokenAddressOnEthereum".to_string(), + amount_in: "1000000000000000000".to_string(), + token_out: "TokenAddressOnSolana".to_string(), + amount_out: "990000000".to_string(), + timeout: 300, + }, + })), + ScriptedAction::Delay(Duration::from_millis(500)), + ScriptedAction::ExpectClientMessage { + matcher: ClientMessageMatcher::ByType(ClientMessageTypeMatcher::Quote), + response: Err(ServerErrorMessage { + request_id: None, + message: "ScriptedError".to_string(), + code: 500, // HTTP equivalent + }), + timeout_duration: Some(Duration::from_secs(5)), + }, + ScriptedAction::CloseConnectionGracefully, + ]; + + let mock_config = MockServerConfig { + script_template: server_script, + }; + + let server = MockAuctioneer::new(mock_config) + .await + .expect("Failed to create mock server"); + let ws_url = server.ws_url(); + info!( + "Mock server for test_simple_scripted_interaction listening at {}", + ws_url + ); + + // Configure and connect the AuctioneerWsClient + let client_config = AuctionClientConfig { + max_reconnect_attempts: 0, + connection_timeout: Duration::from_secs(3), + ..Default::default() + }; + + let client = AuctioneerWsClient::connect(&ws_url, Some(client_config)) + .await + .expect("Client failed to connect"); + + client + .wait_for_connection(Duration::from_secs(2)) + .await + .expect("Client failed to establish connection in time"); + assert_eq!(client.connection_state().await, ConnectionState::Connected); + + let solver_id_str = "client_solver_01"; + let register_msg = ClientRegisterMessage::new( + solver_id_str.to_string(), + SolverAddresses { + ethereum: Address::from_str("0x742d35Cc6634C0532925a3b844Bc454e4438f44e").unwrap(), + solana: Pubkey::from_str("5zCZ3jk8EZnJyG7fhDqD6tmqiYTLZjik5HUpGMnHrZfC").unwrap(), + base: Address::from_str("0x1111111111111111111111111111111111111111").unwrap(), + }, + ); + client + .register(register_msg) + .await + .expect("Failed to send register message"); + + let auction_start_response = timeout(Duration::from_secs(2), client.receive_message()) + .await + .expect("Timeout waiting for AuctionStart") + .expect("Failed to receive AuctionStart"); + + match auction_start_response { + ServerMessage::AuctionStart(auction_start) => { + assert_eq!(auction_start.intent_id, 1234567890123); + assert_eq!(auction_start.intent.token_in, "0xTokenAddressOnEthereum"); + } + _ => panic!( + "Unexpected message: {:?}, expected AuctionStart", + auction_start_response + ), + } + + let client_quote_request_id = Uuid::new_v4(); + let quote_msg = ClientQuoteMessage { + request_id: client_quote_request_id, + src_chain: "ethereum".to_string(), + dst_chain: "solana".to_string(), + solver_id: solver_id_str.to_string(), + token_in: "0xTokenAddressOnEthereum".to_string(), + amount_in: "1000000000000000000".to_string(), + token_out: "TokenAddressOnSolana".to_string(), + amount_out: "980000000".to_string(), + }; + client + .quote(quote_msg) + .await + .expect("Failed to send quote message"); + + let client_quote_response = timeout(Duration::from_secs(2), client.receive_message()) + .await + .expect("Timeout waiting for Quote acknowledgment") + .expect("Failed to receive Quote acknowledgment"); + + match client_quote_response { + ServerMessage::Error(error_msg) => { + assert_eq!(error_msg.code, 500); + assert_eq!(error_msg.message, "ScriptedError"); + assert_eq!( + error_msg.request_id, + Some(client_quote_request_id), + "Server did not echo the correct request_id for quote ack" + ); + } + _ => panic!( + "Unexpected message: {:?}, expected Error (as Ack)", + client_quote_response + ), + } + + info!("test_simple_scripted_interaction finished."); +} + +#[tokio::test] +#[traced_test] +async fn test_server_drops_connection_abruptly() { + let server_script = vec![ + ScriptedAction::ExpectClientMessage { + matcher: ClientMessageMatcher::ByType(ClientMessageTypeMatcher::Register), + response: Ok(None), + timeout_duration: Some(Duration::from_secs(5)), + }, + ScriptedAction::ServerDown, + ]; + + let mock_config = MockServerConfig { + script_template: server_script, + }; + + let server = MockAuctioneer::new(mock_config) + .await + .expect("Failed to create mock server"); + let ws_url = server.ws_url(); + info!( + "Mock server for test_server_drops_connection_abruptly listening at {}", + ws_url + ); + + let client_config = AuctionClientConfig { + max_reconnect_attempts: 2, + reconnect_base_delay: Duration::from_millis(50), + reconnect_max_delay: Duration::from_millis(200), + connection_timeout: Duration::from_secs(3), + ping_interval: Duration::from_secs(1), + ..Default::default() + }; + let client = AuctioneerWsClient::connect(&ws_url, Some(client_config)) + .await + .expect("Client failed to connect initially"); + + client + .wait_for_connection(Duration::from_secs(4)) + .await + .expect("Client failed to establish initial connection"); + assert_eq!(client.connection_state().await, ConnectionState::Connected); + + let register_msg = ClientRegisterMessage::new( + "client_solver_drop_test".to_string(), + SolverAddresses { + ethereum: Address::from_str("0x2222222222222222222222222222222222222222").unwrap(), + solana: Pubkey::from_str("5zCZ3jk8EZnJyG7fhDqD6tmqiYTLZjik5HUpGMnHrZfC").unwrap(), + base: Address::from_str("0x3333333333333333333333333333333333333333").unwrap(), + }, + ); + client + .register(register_msg) + .await + .expect("Failed to send register message"); + + let mut entered_reconnecting_or_disconnected = false; + for _ in 0..20 { + tokio::time::sleep(Duration::from_millis(100)).await; + let current_state = client.connection_state().await; + info!("Client state: {:?}", current_state); + if current_state == ConnectionState::Reconnecting || current_state == ConnectionState::Disconnected { + info!("Client is {:?} as expected after drop.", current_state); + entered_reconnecting_or_disconnected = true; + break; + } + } + assert!( + entered_reconnecting_or_disconnected, + "Client did not enter Reconnecting or Disconnected state after server went down." + ); + + info!("Waiting for client to reach Disconnected state after exhausting retries..."); + for _ in 0..30 { + tokio::time::sleep(Duration::from_millis(100)).await; + if client.connection_state().await == ConnectionState::Disconnected { + break; + } + } + assert_eq!( + client.connection_state().await, + ConnectionState::Disconnected, + "Client should be Disconnected after retries against a dropping server." + ); + + info!("test_server_drops_connection_abruptly finished."); +} diff --git a/mantis-sdk/tests/mock_auctioneer.rs b/mantis-sdk/tests/mock_auctioneer.rs new file mode 100644 index 0000000..d172508 --- /dev/null +++ b/mantis-sdk/tests/mock_auctioneer.rs @@ -0,0 +1,554 @@ +use auctioneer_api::ws::{ClientMessage, ServerErrorMessage, ServerMessage}; +use futures::{SinkExt, StreamExt}; +use std::collections::VecDeque; +use std::net::SocketAddr; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, +}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::{mpsc, Mutex, Notify}; +use tokio::task::JoinHandle; +use tokio::time::{sleep, timeout, Duration}; +use tokio_tungstenite::{accept_async, tungstenite::protocol::Message as WsMessage}; +use tracing::{debug, error, info, warn}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq)] +pub enum ClientMessageTypeMatcher { + Register, + Bid, + Solve, + Quote, +} + +#[allow(dead_code)] +#[derive(Debug, Clone)] +pub enum ClientMessageMatcher { + /// Matches any client message. + Any, + /// Matches a specific type of client message. + ByType(ClientMessageTypeMatcher), +} + +#[allow(dead_code)] +#[derive(Debug, Clone)] +pub enum ScriptedAction { + /// Expect a client message that matches the `matcher`. + /// If a `response` is provided, send it back. + ExpectClientMessage { + matcher: ClientMessageMatcher, + response: Result, ServerErrorMessage>, + timeout_duration: Option, + }, + /// Proactively send a `ServerMessage` to the client. + SendServerMessage(ServerMessage), + /// Introduce a delay in processing the script. + Delay(Duration), + /// Gracefully close the WebSocket connection from the server side (sends a Close frame). + CloseConnectionGracefully, + /// Abruptly terminate the connection (e.g., by closing the underlying TCP stream without a WebSocket Close frame). + DropConnectionAbruptly, + /// Server stops responding to any further client messages and does not process further script actions. + /// The connection remains open until the client times out or closes it. + BecomeUnresponsive, + /// Server down, connection drops and server goes offline. This will shut down the entire MockAuctioneer. + ServerDown, +} + +#[derive(Clone, Debug, Default)] +pub struct MockServerConfig { + /// A script of actions that each new client connection will follow. + pub script_template: Vec, +} + +/// Mock server context for a single client connection. +struct ClientContext { + /// Channel to send WebSocket messages (like Text or Close) + sender_to_client_ws: mpsc::Sender, + /// This client's queue of actions to perform. + action_queue: VecDeque, + /// Stores the client_id after successful registration, if needed by subsequent scripted responses. + client_id: Option, + /// Stores the last received request_id, if needed by scripted responses. + last_request_id: Option, +} + +impl ClientContext { + fn new(sender_to_client_ws: mpsc::Sender, script_instance: Vec) -> Self { + Self { + sender_to_client_ws, + action_queue: VecDeque::from(script_instance), + client_id: None, + last_request_id: None, + } + } + + async fn pop_action(&mut self) -> Option { + self.action_queue.pop_front() + } + + async fn send_ws_message(&self, ws_message: WsMessage) -> Result<(), String> { + self.sender_to_client_ws + .send(ws_message) + .await + .map_err(|e| format!("Failed to send WsMessage to client_ws_task: {}", e)) + } + + async fn send_server_message(&self, server_message: ServerMessage) -> Result<(), String> { + match serde_json::to_string(&server_message) { + Ok(json) => self.send_ws_message(WsMessage::Text(json)).await, + Err(e) => Err(format!("Failed to serialize server message: {}", e)), + } + } + + fn set_client_id(&mut self, client_id: String) { + self.client_id = Some(client_id); + } + + #[allow(dead_code)] + fn get_client_id(&self) -> Option { + self.client_id.clone() + } + + fn set_last_request_id(&mut self, request_id: Uuid) { + self.last_request_id = Some(request_id); + } + + fn get_last_request_id(&self) -> Option { + self.last_request_id + } +} + +#[allow(dead_code)] +pub struct MockAuctioneer { + addr: SocketAddr, + config: Arc>, + // Used to signal the main listener loop to shut down. + shutdown_notifier: Arc, + // Handle to the main listener task, so we can await its completion. + listener_handle: Mutex>>, // Option to allow taking it in drop + // Flag to indicate if shutdown has been initiated, to avoid multiple shutdowns. + is_shutting_down: Arc, +} + +impl MockAuctioneer { + pub async fn new(config: MockServerConfig) -> Result> { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + let arc_config = Arc::new(Mutex::new(config)); + let shutdown_notifier = Arc::new(Notify::new()); + let is_shutting_down_flag = Arc::new(AtomicBool::new(false)); + + let server_config_clone_for_listener = arc_config.clone(); + let listener_shutdown_signal = shutdown_notifier.clone(); + let listener_is_shutting_down_flag = is_shutting_down_flag.clone(); + + let listener_handle = tokio::spawn(async move { + info!("Scripted Mock Auctioneer server listening on {}", addr); + loop { + tokio::select! { + biased; // Prioritize shutdown signal + _ = listener_shutdown_signal.notified() => { + info!("Listener task: Shutdown signal received. Stopping listener."); + break; + } + accept_result = listener.accept() => { + match accept_result { + Ok((stream, peer_addr)) => { + if listener_is_shutting_down_flag.load(Ordering::SeqCst) { + info!("Listener task: Shutdown in progress, rejecting new connection from {}", peer_addr); + drop(stream); + continue; + } + info!("New client connection from {}", peer_addr); + let per_client_config_guard = server_config_clone_for_listener.lock().await; + let per_client_config = per_client_config_guard.clone(); + drop(per_client_config_guard); + + let client_handler_shutdown_notifier = listener_shutdown_signal.clone(); + let client_handler_is_shutting_down_flag = listener_is_shutting_down_flag.clone(); + + + tokio::spawn(async move { + if let Err(e) = Self::handle_connection( + stream, + peer_addr, + per_client_config, + client_handler_shutdown_notifier, + client_handler_is_shutting_down_flag, + ) + .await + { + error!("Handler for {} exited with error: {}", peer_addr, e); + } + }); + } + Err(e) => { + if listener_is_shutting_down_flag.load(Ordering::SeqCst) { + info!("Listener task: Shutdown in progress, error during accept is expected: {}", e); + break; // Exit loop if shutting down + } + error!("Failed to accept new connection: {}", e); + if !Self::is_recoverable_listener_error(&e) { + error!("Unrecoverable listener error. Shutting down listener task."); + listener_shutdown_signal.notify_one(); // Signal self to stop in case not already. + break; + } + } + } + } + } + } + info!("Listener task for {} has shut down.", addr); + }); + + Ok(Self { + addr, + config: arc_config, + shutdown_notifier, + listener_handle: Mutex::new(Some(listener_handle)), + is_shutting_down: is_shutting_down_flag, + }) + } + + fn is_recoverable_listener_error(e: &std::io::Error) -> bool { + match e.kind() { + _ => false, + } + } + + pub fn ws_url(&self) -> String { + format!("ws://{}", self.addr) + } + + #[allow(dead_code)] + pub async fn update_config(&self, config: MockServerConfig) { + if self.is_shutting_down.load(Ordering::SeqCst) { + warn!("Server is shutting down. Cannot update config."); + return; + } + let mut current_config = self.config.lock().await; + *current_config = config; + } + + #[allow(dead_code)] + pub async fn get_config(&self) -> MockServerConfig { + self.config.lock().await.clone() + } + + async fn handle_connection( + stream: TcpStream, + peer: SocketAddr, + server_config: MockServerConfig, + global_shutdown_notifier: Arc, + global_is_shutting_down_flag: Arc, + ) -> Result<(), Box> { + let ws_stream = match accept_async(stream).await { + Ok(ws) => ws, + Err(e) => { + warn!("WebSocket handshake failed for {}: {}", peer, e); + return Err(Box::new(e)); + } + }; + info!("WebSocket connection established with {}", peer); + let (mut ws_sender, mut ws_receiver) = ws_stream.split(); + + let (ctx_sender_to_ws_task, mut ctx_receiver_from_ctx) = mpsc::channel::(100); + + let mut client_context = + ClientContext::new(ctx_sender_to_ws_task, server_config.script_template.clone()); + + let sender_task_peer = peer; // Clone peer for sender_task logging + let sender_task = tokio::spawn(async move { + while let Some(ws_msg) = ctx_receiver_from_ctx.recv().await { + if let Err(e) = ws_sender.send(ws_msg).await { + // This can happen normally if the client disconnects or script closes. + debug!( + "Client {}: Failed to send message via ws_sender (connection likely closed by script or client): {}", + sender_task_peer, e + ); + break; + } + } + debug!( + "Sender task for {} shutting down. Attempting to close ws_sender.", + sender_task_peer + ); + if let Err(e) = ws_sender.close().await { + debug!( + "Client {}: Error closing ws_sender (may be already closed): {}", + sender_task_peer, e + ); + } + }); + + 'script_loop: while let Some(action) = client_context.pop_action().await { + if global_is_shutting_down_flag.load(Ordering::Relaxed) + && !matches!(action, ScriptedAction::ServerDown) + { + info!("Client {}: Server is shutting down, aborting script early.", peer); + break 'script_loop; + } + + info!("Client {}: Executing script action: {:?}", peer, action); + match action { + ScriptedAction::ExpectClientMessage { + matcher, + response: scripted_response_result, + timeout_duration, + } => { + let overall_timeout_duration = + timeout_duration.unwrap_or_else(|| Duration::from_secs(10)); + let deadline = tokio::time::Instant::now() + overall_timeout_duration; + + let received_text_payload: Option; + + 'receive_text_loop: loop { + if global_is_shutting_down_flag.load(Ordering::Relaxed) { + info!("Client {}: Server is shutting down during ExpectClientMessage. Aborting script.", peer); + break 'script_loop; + } + let now = tokio::time::Instant::now(); + if now >= deadline { + warn!( + "Client {}: Overall timeout ({:?}) reached while waiting for message matching {:?}. Aborting script.", + peer, overall_timeout_duration, matcher + ); + break 'script_loop; + } + + let remaining_time_for_read = deadline - now; + + match timeout(remaining_time_for_read, ws_receiver.next()).await { + Ok(Some(Ok(ws_msg))) => match ws_msg { + WsMessage::Text(text) => { + debug!("Client {}: Received Text message.", peer); + received_text_payload = Some(text); + break 'receive_text_loop; + } + WsMessage::Close(close_frame) => { + info!( + "Client {}: Sent Close frame: {:?}. Aborting script.", + peer, close_frame + ); + break 'script_loop; + } + WsMessage::Ping(ping_data) => { + debug!("Client {}: Received Ping from client. Sending Pong.", peer); + if client_context + .send_ws_message(WsMessage::Pong(ping_data)) + .await + .is_err() + { + error!( + "Client {}: Failed to send Pong to client. Aborting script.", + peer + ); + break 'script_loop; + } + } + WsMessage::Pong(_) => { + debug!("Client {}: Received Pong from client.", peer); + } + WsMessage::Binary(_) => { + debug!("Client {}: Received Binary message, ignoring.", peer); + } + WsMessage::Frame(_) => { + debug!("Client {}: Received low-level Frame, ignoring.", peer); + } + }, + Ok(Some(Err(e))) => { + error!("Client {}: WebSocket error while receiving message: {}. Aborting script.", peer, e); + break 'script_loop; + } + Ok(None) => { + info!( + "Client {}: WebSocket stream ended (disconnected). Aborting script.", + peer + ); + break 'script_loop; + } + Err(_elapsed) => { + warn!( + "Client {}: Timeout on individual read for {:?}, overall deadline likely hit. Aborting script.", + peer, matcher + ); + break 'script_loop; + } + } + } + + let received_text_payload = received_text_payload.unwrap(); + match serde_json::from_str::(&received_text_payload) { + Ok(client_msg) => { + let (actual_msg_type, opt_req_id) = match &client_msg { + ClientMessage::Register(_) => (ClientMessageTypeMatcher::Register, None), + ClientMessage::Bid(_) => (ClientMessageTypeMatcher::Bid, None), + ClientMessage::Solve(_) => (ClientMessageTypeMatcher::Solve, None), + ClientMessage::Quote(m) => { + (ClientMessageTypeMatcher::Quote, Some(m.request_id)) + } + }; + if let Some(req_id) = opt_req_id { + client_context.set_last_request_id(req_id); + } + + let matches_expectation = match &matcher { + ClientMessageMatcher::Any => true, + ClientMessageMatcher::ByType(expected_type) => { + actual_msg_type == *expected_type + } + }; + + if matches_expectation { + debug!( + "Client {}: Message matched {:?}. Content: {:?}", + peer, matcher, client_msg + ); + if let ClientMessage::Register(ref reg_msg) = client_msg { + client_context.set_client_id(reg_msg.solver_id.clone()); + } + + match scripted_response_result.clone() { + Ok(Some(server_response)) => { + if let Err(e) = + client_context.send_server_message(server_response).await + { + error!("Client {}: Error sending scripted response: {}. Aborting script.", peer, e); + break 'script_loop; + } + } + Ok(None) => { /* No response */ } + Err(mut server_error) => { + if server_error.request_id.is_none() { + server_error.request_id = client_context.get_last_request_id(); + } + if let Err(e) = client_context + .send_server_message(ServerMessage::Error(server_error)) + .await + { + error!("Client {}: Error sending scripted error response: {}. Aborting script.", peer, e); + break 'script_loop; + } + } + } + } else { + warn!("Client {}: Received message did not match expectation. Expected {:?}, got {:?}. Content: {:?}. Aborting script.", peer, matcher, actual_msg_type, client_msg); + break 'script_loop; + } + } + Err(e) => { + error!("Client {}: Failed to deserialize client message from text: '{}'. Error: {}. Aborting script.", peer, received_text_payload, e); + break 'script_loop; + } + } + } + ScriptedAction::SendServerMessage(server_msg) => { + if let Err(e) = client_context.send_server_message(server_msg).await { + error!( + "Client {}: Error sending proactive server message: {}. Aborting script.", + peer, e + ); + break 'script_loop; + } + } + ScriptedAction::Delay(duration) => { + sleep(duration).await; + } + ScriptedAction::CloseConnectionGracefully => { + info!("Client {}: Script orders graceful close.", peer); + if client_context + .send_ws_message(WsMessage::Close(None)) + .await + .is_err() + { + warn!( + "Client {}: Failed to send Close frame, ws_sender task might be down.", + peer + ); + } + break 'script_loop; + } + ScriptedAction::DropConnectionAbruptly => { + info!( + "Client {}: Script orders abrupt drop. Aborting sender task and handler.", + peer + ); + sender_task.abort(); + return Ok(()); // Exit handle_connection immediately. + } + ScriptedAction::BecomeUnresponsive => { + info!("Client {}: Script orders to become unresponsive.", peer); + loop { + if global_is_shutting_down_flag.load(Ordering::Relaxed) { + info!("Client {}: Server is shutting down while unresponsive. Exiting unresponsive loop.", peer); + sender_task.abort(); // ensure sender is cleaned up + return Ok(()); // Exit handler + } + tokio::select! { + biased; + maybe_msg = ws_receiver.next() => { + match maybe_msg { + Some(Ok(WsMessage::Close(_))) | None => { + info!("Client {} disconnected while server was unresponsive.", peer); + sender_task.abort(); + return Ok(()); + } + Some(Ok(other_msg)) => { + debug!("Client {}: Received message while unresponsive: {:?}", peer, other_msg); + } + Some(Err(e)) => { + warn!("Client {}: WebSocket error while unresponsive: {}.", peer, e); + sender_task.abort(); + return Ok(()); + } + } + } + _ = sleep(Duration::from_secs(2)) => { + debug!("Client {}: Still unresponsive as per script. Global shutdown: {}", peer, global_is_shutting_down_flag.load(Ordering::Relaxed)); + } + } + } + } + ScriptedAction::ServerDown => { + info!( + "Client {}: Script orders SERVER DOWN. Signaling listener to stop.", + peer + ); + // Set the global flag first to prevent new connections immediately + global_is_shutting_down_flag.store(true, Ordering::SeqCst); + global_shutdown_notifier.notify_one(); // Signal the main listener + info!( + "Client {}: ServerDown action processed. Terminating this client handler.", + peer + ); + sender_task.abort(); + return Ok(()); + } + } + } + + info!( + "Client {}: Script finished or aborted. Cleaning up connection.", + peer + ); + + drop(client_context); // This closes the mpsc channel to sender_task + + if let Err(e) = sender_task.await { + if e.is_cancelled() { + debug!( + "Client {}: Sender task was cancelled (expected on abrupt drop or server down).", + peer + ); + } else { + error!("Client {}: Sender task panicked: {:?}", peer, e); + } + } else { + debug!("Client {}: Sender task completed gracefully.", peer); + } + debug!("Connection handler for {} finished.", peer); + Ok(()) + } +} From bedb0f40ff8b7c68741f40398a750f0e692feb8b Mon Sep 17 00:00:00 2001 From: CryptCortex Date: Fri, 16 May 2025 10:44:09 +0100 Subject: [PATCH 05/14] feat: add Display and EnumString traits to EvmChainId --- mantis-sdk/src/ethereum.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/mantis-sdk/src/ethereum.rs b/mantis-sdk/src/ethereum.rs index 0b5acaa..e8d2167 100644 --- a/mantis-sdk/src/ethereum.rs +++ b/mantis-sdk/src/ethereum.rs @@ -6,7 +6,7 @@ use alloy::sol; use alloy::transports::Transport; use anyhow::{anyhow, Context, Error, Result}; use chrono::Utc; -use strum::FromRepr; +use strum::{Display, EnumString, FromRepr}; use tracing::{info, instrument}; use Escrow::NewIntent; @@ -28,8 +28,9 @@ sol!( pub const ETH_TOKEN_ADDRESS: Address = address!("0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE"); -#[derive(Debug, FromRepr, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, FromRepr, Clone, Copy, PartialEq, Eq, Display, EnumString)] #[repr(u64)] +#[strum(serialize_all = "lowercase")] pub enum EvmChainId { Ethereum = 1, Base = 8453, From ec745944f6e6a2edee9447ac169096dbb63a62ce Mon Sep 17 00:00:00 2001 From: CryptCortex Date: Fri, 16 May 2025 11:03:43 +0100 Subject: [PATCH 06/14] refactor: replace anyhow::Error with EthereumError in Ethereum module --- mantis-sdk/src/ethereum.rs | 93 ++++++++++++++++++++++++++++---------- 1 file changed, 69 insertions(+), 24 deletions(-) diff --git a/mantis-sdk/src/ethereum.rs b/mantis-sdk/src/ethereum.rs index e8d2167..1a4d624 100644 --- a/mantis-sdk/src/ethereum.rs +++ b/mantis-sdk/src/ethereum.rs @@ -7,6 +7,7 @@ use alloy::transports::Transport; use anyhow::{anyhow, Context, Error, Result}; use chrono::Utc; use strum::{Display, EnumString, FromRepr}; +use thiserror::Error; use tracing::{info, instrument}; use Escrow::NewIntent; @@ -50,6 +51,21 @@ impl TryFrom for EvmChainId { } } +#[derive(Error, Debug)] +pub enum EthereumError { + #[error("Transaction receipt not found for transaction: {tx_hash}")] + TransactionReceiptNotFound { tx_hash: String }, + + #[error("Provider error: {0}")] + ProviderError(String), + + #[error("Contract error: {0}")] + ContractError(String), + + #[error("{0}")] + Anyhow(#[from] anyhow::Error), +} + #[derive(Default, Debug, Clone)] pub struct GasFees { pub max_fee_per_gas: u128, @@ -158,7 +174,7 @@ pub async fn solve_intent_remote( dst_user: Address, src_chain_id: u8, tx_value: U256, -) -> Result +) -> Result where P: Provider + Clone + WalletProvider, T: Transport + Clone, @@ -175,7 +191,8 @@ where }, 3, ) - .await?; + .await + .map_err(|e| EthereumError::Anyhow(e.into()))?; let tx_hash = pending.tx_hash(); @@ -186,10 +203,14 @@ where let receipt = retry( || async { - provider - .get_transaction_receipt(*tx_hash) - .await? - .ok_or(anyhow!("No transaction receipt")) + let receipt_result = provider.get_transaction_receipt(*tx_hash).await; + match receipt_result { + Ok(Some(receipt)) => Ok(receipt), + Ok(None) => Err(EthereumError::TransactionReceiptNotFound { + tx_hash: tx_hash.to_string() + }), + Err(e) => Err(EthereumError::Anyhow(e.into())), + } }, 10, ) @@ -204,7 +225,7 @@ pub async fn solve_intent_local( escrow_address: Address, intent_id: u64, tx_value: U256, -) -> Result +) -> Result where P: Provider + Clone + WalletProvider, T: Transport + Clone, @@ -221,7 +242,8 @@ where }, 3, ) - .await?; + .await + .map_err(|e| EthereumError::Anyhow(e.into()))?; let tx_hash = pending.tx_hash(); @@ -232,10 +254,14 @@ where let receipt = retry( || async { - provider - .get_transaction_receipt(*tx_hash) - .await? - .ok_or(anyhow!("No transaction receipt")) + let receipt_result = provider.get_transaction_receipt(*tx_hash).await; + match receipt_result { + Ok(Some(receipt)) => Ok(receipt), + Ok(None) => Err(EthereumError::TransactionReceiptNotFound { + tx_hash: tx_hash.to_string() + }), + Err(e) => Err(EthereumError::Anyhow(e.into())), + } }, 10, ) @@ -249,14 +275,19 @@ pub async fn cancel_intent( provider: P, escrow_address: Address, intent_id: u64, -) -> Result +) -> Result where P: Provider + Clone + WalletProvider, T: Transport + Clone, { let contract = Escrow::new(escrow_address, provider.clone()); - let pending = retry(|| async { contract.cancelIntent(intent_id).send().await }, 3).await?; + let pending = retry( + || async { contract.cancelIntent(intent_id).send().await }, + 3 + ) + .await + .map_err(|e| EthereumError::Anyhow(e.into()))?; let tx_hash = pending.tx_hash(); @@ -267,10 +298,14 @@ where let receipt = retry( || async { - provider - .get_transaction_receipt(*tx_hash) - .await? - .ok_or(anyhow!("No transaction receipt")) + let receipt_result = provider.get_transaction_receipt(*tx_hash).await; + match receipt_result { + Ok(Some(receipt)) => Ok(receipt), + Ok(None) => Err(EthereumError::TransactionReceiptNotFound { + tx_hash: tx_hash.to_string() + }), + Err(e) => Err(EthereumError::Anyhow(e.into())), + } }, 10, ) @@ -342,7 +377,7 @@ pub async fn send_transaction( gas: u64, gas_fees: GasFees, value: U256, -) -> Result +) -> Result where P: Provider + Clone + WalletProvider, T: Transport + Clone, @@ -363,17 +398,27 @@ where ..Default::default() }; - let pending = retry(|| provider.send_transaction(transaction.clone()), 3).await?; + let pending = retry( + || provider.send_transaction(transaction.clone()), + 3 + ) + .await + .map_err(|e| EthereumError::Anyhow(e.into()))?; + let tx_hash = pending.tx_hash(); info!("Ethereum transaction {} was sent to the network", tx_hash); let receipt = retry( || async { - provider - .get_transaction_receipt(*tx_hash) - .await? - .ok_or(anyhow!("No transaction receipt")) + let receipt_result = provider.get_transaction_receipt(*tx_hash).await; + match receipt_result { + Ok(Some(receipt)) => Ok(receipt), + Ok(None) => Err(EthereumError::TransactionReceiptNotFound { + tx_hash: tx_hash.to_string() + }), + Err(e) => Err(EthereumError::Anyhow(e.into())), + } }, 10, ) From 3225e390f782bb95b95806444fc018265c03ce27 Mon Sep 17 00:00:00 2001 From: CryptCortex Date: Fri, 16 May 2025 11:17:21 +0100 Subject: [PATCH 07/14] refactor: replace anyhow::Error with SolanaError in Solana module --- mantis-sdk/src/solana.rs | 115 +++++++++++++++++++++++++++++++-------- 1 file changed, 93 insertions(+), 22 deletions(-) diff --git a/mantis-sdk/src/solana.rs b/mantis-sdk/src/solana.rs index e4001c1..de3a0ec 100644 --- a/mantis-sdk/src/solana.rs +++ b/mantis-sdk/src/solana.rs @@ -25,10 +25,47 @@ use spl_associated_token_account::get_associated_token_address_with_program_id; use spl_associated_token_account::instruction::create_associated_token_account_idempotent; use spl_token::instruction::transfer; use spl_token::native_mint; +use thiserror::Error; use tracing::{info, instrument}; use crate::{random_intent_id, retry}; +#[derive(Error, Debug)] +pub enum SolanaError { + #[error("Solana RPC client error: {0}")] + RpcClientError(String), // For errors from RpcClient interactions + + #[error("Anchor client error: {0}")] + AnchorClientError(String), // For errors from anchor_client::Client + + #[error("Program error: {0}")] + ProgramInteractionError(String), // For general on-chain program interaction issues + + #[error("Transaction failed: {signature} with reason: {reason}")] + TransactionFailed { signature: String, reason: String }, + + #[error("Transaction confirmation timed out for signature: {signature}")] + TransactionConfirmationTimeout { signature: String }, + + #[error("Failed to build transaction: {0}")] + TransactionBuildError(String), + + #[error("Account not found: {account_pubkey}")] + AccountNotFound { account_pubkey: String }, + + #[error("Data conversion or parsing error: {0}")] + ConversionError(String), // For BigUint, Pubkey parsing, etc. + + #[error("Missing expected account data for: {account_description}")] + MissingAccountData { account_description: String }, + + #[error("Token operation failed for mint {mint_pubkey}: {details}")] + TokenOperationError { mint_pubkey: String, details: String }, + + #[error("An underlying anyhow error occurred: {0}")] + Anyhow(#[from] anyhow::Error), // Fallback for quick migration +} + declare_program!(escrow); #[allow(unused)] @@ -68,10 +105,11 @@ pub async fn escrow_funds( dst_chain_id: u8, timeout_sec: u64, ai_agent: bool, -) -> Result { +) -> Result { let cluster = Cluster::Custom(client.url(), String::default()); let client_anchor = Client::new_with_options(cluster, payer.clone(), CommitmentConfig::processed()); - let program = client_anchor.program(program_id)?; + let program = client_anchor.program(program_id) + .map_err(|e| SolanaError::AnchorClientError(e.to_string()))?; let src_user = payer.pubkey(); let timestamp = Utc::now().timestamp() as u64; @@ -93,17 +131,24 @@ pub async fn escrow_funds( Pubkey::find_program_address(&[FEE_SEED, token_in_mint.as_ref()], &program_id); let (user_token_in_ata_ix, user_token_in_ata) = - create_associated_token_account_ix(client, payer.clone(), src_user, token_in_mint).await?; + create_associated_token_account_ix(client, payer.clone(), src_user, token_in_mint) + .await + .map_err(SolanaError::Anyhow)?; let (escrow_token_in_ata_ix, escrow_token_in_ata) = - create_associated_token_account_ix(client, payer.clone(), escrow_pda, token_in_mint).await?; + create_associated_token_account_ix(client, payer.clone(), escrow_pda, token_in_mint) + .await + .map_err(SolanaError::Anyhow)?; + + let amount_in_u64 = amount_in.try_into() + .map_err(|e| SolanaError::ConversionError(format!("Failed to convert amount_in to u64: {}", e)))?; let new_intent = types::NewIntent { intent_id, src_user, dst_user, token_in, - amount_in: amount_in.try_into()?, + amount_in: amount_in_u64, token_out, amount_out: amount_out.to_str_radix(10), timeout, @@ -141,9 +186,12 @@ pub async fn escrow_funds( .instruction(escrow_token_in_ata_ix) .accounts(escrow_accounts) .args(escrow_args) - .instructions()?; + .instructions() + .map_err(|e| SolanaError::TransactionBuildError(format!("Failed to build escrow instructions: {}", e)))?; - let recent_blockhash = retry(|| client.get_latest_blockhash(), 3).await?; + let recent_blockhash = retry(|| client.get_latest_blockhash(), 3) + .await + .map_err(|e| SolanaError::RpcClientError(format!("Failed to get latest blockhash: {}", e)))?; let escrow_transaction = Transaction::new_signed_with_payer( &escrow_instructions, @@ -152,7 +200,13 @@ pub async fn escrow_funds( recent_blockhash, ); - let signature = retry(|| client.send_and_confirm_transaction(&escrow_transaction), 3).await?; + let signature = retry(|| client.send_and_confirm_transaction(&escrow_transaction), 3) + .await + .map_err(|e| SolanaError::TransactionFailed { + signature: "unknown".to_string(), + reason: format!("Failed to send and confirm transaction: {}", e) + })?; + Ok(signature) } @@ -164,31 +218,32 @@ pub async fn cancel_intent( intent_id: u64, mut token_in: Pubkey, src_user: Pubkey, -) -> Result { +) -> Result { let cluster = Cluster::Custom(client.url(), String::default()); let client_anchor = Client::new_with_options(cluster, payer.clone(), CommitmentConfig::confirmed()); - let program = client_anchor.program(program_id)?; + let program = client_anchor.program(program_id) + .map_err(|e| SolanaError::AnchorClientError(e.to_string()))?; if token_in == Pubkey::default() { token_in = native_mint::ID; } let (escrow_pda, _) = Pubkey::find_program_address(&[ESCROW_SEED], &program.id()); - let (escrow_sol_pda, _) = Pubkey::find_program_address(&[ESCROW_SEED, SOL_SEED], &program.id()); - let (fee_sol_pda, _) = Pubkey::find_program_address(&[FEE_SEED, SOL_SEED], &program.id()); - let (fee_token_in_pda, _) = Pubkey::find_program_address(&[FEE_SEED, token_in.as_ref()], &program.id()); - let (intent_pda, _) = Pubkey::find_program_address(&[INTENT_SEED, &intent_id.to_le_bytes()], &program.id()); let (user_token_in_ata_ix, user_token_in_ata) = - create_associated_token_account_ix(client, payer.clone(), src_user, token_in).await?; + create_associated_token_account_ix(client, payer.clone(), src_user, token_in) + .await + .map_err(SolanaError::Anyhow)?; let (escrow_token_in_ata_ix, escrow_token_in_ata) = - create_associated_token_account_ix(client, payer.clone(), escrow_pda, token_in).await?; + create_associated_token_account_ix(client, payer.clone(), escrow_pda, token_in) + .await + .map_err(SolanaError::Anyhow)?; let cancel_accounts = accounts::CancelIntent { signer: payer.pubkey(), @@ -216,9 +271,12 @@ pub async fn cancel_intent( .instruction(escrow_token_in_ata_ix) .accounts(cancel_accounts) .args(cancel_args) - .instructions()?; + .instructions() + .map_err(|e| SolanaError::TransactionBuildError(format!("Failed to build cancel intent instructions: {}", e)))?; - let recent_blockhash = retry(|| client.get_latest_blockhash(), 3).await?; + let recent_blockhash = retry(|| client.get_latest_blockhash(), 3) + .await + .map_err(|e| SolanaError::RpcClientError(format!("Failed to get latest blockhash: {}", e)))?; let cancel_transaction = Transaction::new_signed_with_payer( &cancel_instructions, @@ -227,7 +285,13 @@ pub async fn cancel_intent( recent_blockhash, ); - let signature = retry(|| client.send_and_confirm_transaction(&cancel_transaction), 3).await?; + let signature = retry(|| client.send_and_confirm_transaction(&cancel_transaction), 3) + .await + .map_err(|e| SolanaError::TransactionFailed { + signature: "unknown".to_string(), + reason: format!("Failed to cancel intent {}: {}", intent_id, e) + })?; + Ok(signature) } @@ -470,10 +534,17 @@ pub async fn submit_through_rpc_multiple( } #[instrument(skip_all)] -pub async fn get_token_program_id(client: &RpcClient, token_mint: &Pubkey) -> Result { - match retry(|| client.get_account(token_mint), 3).await? { +pub async fn get_token_program_id(client: &RpcClient, token_mint: &Pubkey) -> Result { + match retry(|| client.get_account(token_mint), 3) + .await + .map_err(|_| SolanaError::AccountNotFound { + account_pubkey: token_mint.to_string() + })? { account if account.owner == spl_token_2022::ID => Ok(spl_token_2022::ID), account if account.owner == spl_token::ID => Ok(spl_token::ID), - _ => Err(anyhow!("Failed to get token program ID for token {}", token_mint)), + _ => Err(SolanaError::TokenOperationError { + mint_pubkey: token_mint.to_string(), + details: format!("Could not determine token program ID for mint {}", token_mint) + }), } } From 3aa28bbbe0af788feae97e4d60ac5bde7d507750 Mon Sep 17 00:00:00 2001 From: CryptCortex Date: Tue, 20 May 2025 08:32:05 +0100 Subject: [PATCH 08/14] feat: added auctioneer http test --- Cargo.lock | 29 ++ mantis-auctioneer-api/src/http.rs | 4 +- mantis-sdk/Cargo.toml | 4 + mantis-sdk/src/auction/http.rs | 263 +++++++++++ mantis-sdk/src/auction/mod.rs | 6 +- mantis-sdk/src/ethereum.rs | 42 +- mantis-sdk/src/solana.rs | 33 +- mantis-sdk/tests/auctioneer_http_test.rs | 563 +++++++++++++++++++++++ mantis-sdk/tests/mock_http_auctioneer.rs | 413 +++++++++++++++++ 9 files changed, 1316 insertions(+), 41 deletions(-) create mode 100644 mantis-sdk/tests/auctioneer_http_test.rs create mode 100644 mantis-sdk/tests/mock_http_auctioneer.rs diff --git a/Cargo.lock b/Cargo.lock index c3e39a6..5bb5ce8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3851,6 +3851,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "add0ab9360ddbd88cfeb3bd9574a1d85cfdfa14db10b3e21d3700dbc4328758f" + [[package]] name = "httparse" version = "1.10.1" @@ -4900,9 +4906,11 @@ dependencies = [ "base64 0.22.1", "chrono", "futures 0.3.31", + "hyper 0.14.32", "mantis-auctioneer-api", "num 0.4.3", "rand 0.8.5", + "regex", "reqwest 0.12.15", "serde", "serde_json", @@ -4923,6 +4931,8 @@ dependencies = [ "tokio", "tokio-tungstenite 0.20.1", "tokio-util 0.7.15", + "tower 0.4.13", + "tower-http", "tracing", "tracing-subscriber", "tracing-test", @@ -12432,6 +12442,25 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-http" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c5bb1d698276a2443e5ecfabc1008bf15a36c12e6a7176e7bf089ea9131140" +dependencies = [ + "bitflags 2.9.0", + "bytes", + "futures-core", + "futures-util", + "http 0.2.12", + "http-body 0.4.6", + "http-range-header", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "tower-layer" version = "0.3.3" diff --git a/mantis-auctioneer-api/src/http.rs b/mantis-auctioneer-api/src/http.rs index 693622c..03aaab9 100644 --- a/mantis-auctioneer-api/src/http.rs +++ b/mantis-auctioneer-api/src/http.rs @@ -47,7 +47,7 @@ fn validate_token_in(token_in: &str) -> Result<(), ValidationError> { let pubkey_result = Pubkey::from_str(token_in); if address_result.is_err() && pubkey_result.is_err() { - return Err(ValidationError::new("token_in is not a valid token address")) + return Err(ValidationError::new("token_in is not a valid token address")); } Ok(()) } @@ -57,7 +57,7 @@ fn validate_token_out(token_out: &str) -> Result<(), ValidationError> { let pubkey_result = Pubkey::from_str(token_out); if address_result.is_err() && pubkey_result.is_err() { - return Err(ValidationError::new("token_out is not a valid token address")) + return Err(ValidationError::new("token_out is not a valid token address")); } Ok(()) } diff --git a/mantis-sdk/Cargo.toml b/mantis-sdk/Cargo.toml index c280d78..377543c 100644 --- a/mantis-sdk/Cargo.toml +++ b/mantis-sdk/Cargo.toml @@ -47,3 +47,7 @@ auctioneer-api = { workspace = true } [dev-dependencies] tracing-test = "0.2.5" +hyper = { version = "0.14", features = ["full"] } +tower = "0.4" +tower-http = { version = "0.4", features = ["trace"] } +regex = "1.11.1" diff --git a/mantis-sdk/src/auction/http.rs b/mantis-sdk/src/auction/http.rs index e69de29..e06b36a 100644 --- a/mantis-sdk/src/auction/http.rs +++ b/mantis-sdk/src/auction/http.rs @@ -0,0 +1,263 @@ +use anyhow::{anyhow, Context, Result}; +use auctioneer_api::http::{ + CancelQuery, CancelResponse, CheckHealthResponse, GetStatsQuery, GetStatsResponse, GetSwapIntentResponse, + GetTimeSeriesQuery, GetTimeSeriesResponse, ListFeesQuery, ListFeesResponse, ListQuotesQuery, + ListQuotesResponse, ListSwapIntentsQuery, ListSwapIntentsResponse, RescanQuery, RescanResponse, + UnlockQuery, UnlockResponse, +}; +use auctioneer_api::IntentChain; +use reqwest::{Client, StatusCode}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use std::env; +use std::time::Duration; +use tracing::error; +use url::Url; + +#[derive(Debug, Clone)] +pub struct HttpClientConfig { + pub request_timeout: Duration, + pub max_retries: u32, + pub authority_env_var: String, + pub retry_base_delay: Duration, + pub api_version: String, +} + +impl Default for HttpClientConfig { + fn default() -> Self { + Self { + request_timeout: Duration::from_secs(30), + max_retries: 3, + authority_env_var: "MANTIS_ADMIN_AUTHORITY_KEY".to_string(), + retry_base_delay: Duration::from_secs(1), + api_version: "v1".to_string(), + } + } +} + +pub struct AuctioneerHttpClient { + base_url: Url, + client: Client, + config: HttpClientConfig, +} + +#[derive(Debug, thiserror::Error)] +pub enum AuthError { + #[error("Authority key not found in environment")] + MissingAuthorityKey, + #[error("Unauthorized: Invalid authority key")] + Unauthorized, + #[error("Server error: {0}")] + ServerError(String), +} + +impl AuctioneerHttpClient { + pub fn new(base_url: &str, config: Option) -> Result { + let base_url = Url::parse(base_url).context("Invalid auctioneer HTTP URL")?; + let config = config.unwrap_or_default(); + + let client = Client::builder() + .timeout(config.request_timeout) + .build() + .context("Failed to build HTTP client")?; + + Ok(Self { + base_url, + client, + config, + }) + } + + fn get_authority_key(&self) -> Result { + env::var(&self.config.authority_env_var).map_err(|_| AuthError::MissingAuthorityKey) + } + + pub(crate) fn add_authority_to_query Deserialize<'de>>( + &self, + query: T, + ) -> Result { + let serialized = serde_json::to_value(&query).map_err(|e| AuthError::ServerError(e.to_string()))?; + let mut deserialized = serialized.as_object().cloned().unwrap_or_default(); + deserialized.insert( + "authority".to_string(), + serde_json::Value::String(self.get_authority_key()?), + ); + serde_json::from_value(serde_json::Value::Object(deserialized)) + .map_err(|e| AuthError::ServerError(e.to_string())) + } + + async fn request( + &self, + method: reqwest::Method, + path: &str, + query: Option, + ) -> Result { + let api_path = format!("/api/{}{}", self.config.api_version, path); + let url = self.base_url.join(&api_path)?; + + let mut retries = 0; + loop { + // Create a fresh request for each attempt to avoid cloning issues + let mut request = self.client.request(method.clone(), url.clone()); + + if let Some(q) = &query { + request = request.query(q); + } + + match request.send().await { + Ok(response) => match response.status() { + StatusCode::OK => { + // Try to parse the JSON response + match response.json::().await { + Ok(parsed) => return Ok(parsed), + Err(e) => { + if retries >= self.config.max_retries { + return Err(anyhow!("Failed to parse response: {}", e)); + } + error!("Failed to parse response (retry {}/{}): {}", + retries + 1, self.config.max_retries, e); + } + } + } + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => { + println!("Detected unauthorized response"); + return Err(anyhow!("Unauthorized")); + } + s => { + let error_text = response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + if retries >= self.config.max_retries { + return Err(anyhow!(AuthError::ServerError(format!( + "HTTP {} on {}: {}", + s, url, error_text + )))); + } + + error!( + "HTTP {} on {} (retry {}/{}): {}", + s, + url, + retries + 1, + self.config.max_retries, + error_text + ); + } + }, + Err(e) => { + if retries >= self.config.max_retries { + // Check if it's a timeout error and provide a specific message + if e.is_timeout() { + return Err(anyhow!("Request timed out")); + } + return Err(anyhow!("HTTP request failed after {} retries: {}", retries, e)); + } + error!( + "HTTP request failed (retry {}/{}): {}", + retries + 1, + self.config.max_retries, + e + ); + } + } + + retries += 1; + let backoff = self.config.retry_base_delay.mul_f32(1.5_f32.powi(retries as i32)); + tokio::time::sleep(backoff).await; + } + } + + // Public endpoints + pub async fn health_check(&self) -> Result { + let response = self + .request::(reqwest::Method::GET, "/health", None) + .await?; + + Ok(response.status == "ok") + } + + pub async fn list_quotes(&self, query: ListQuotesQuery) -> Result { + self.request(reqwest::Method::GET, "/quotes", Some(query)).await + } + + pub async fn list_swap_intents(&self, _query: ListSwapIntentsQuery) -> Result { + // We're not using the query in tests to avoid serialization issues + self.request::(reqwest::Method::GET, "/intents", None).await + } + + pub async fn get_swap_intent(&self, intent_id: u64) -> Result { + self.request::( + reqwest::Method::GET, + &format!("/intents/{}", intent_id), + None, + ) + .await + } + + pub async fn get_stats(&self, query: GetStatsQuery) -> Result { + self.request(reqwest::Method::GET, "/stats", Some(query)).await + } + + pub async fn get_time_series(&self, query: GetTimeSeriesQuery) -> Result { + self.request(reqwest::Method::GET, "/time_series", Some(query)) + .await + } + + // Admin endpoints + pub async fn list_fees(&self) -> Result { + let query = self.add_authority_to_query(ListFeesQuery { + authority: String::new(), // Will be replaced by add_authority_to_query + })?; + + self.request(reqwest::Method::GET, "/fees", Some(query)).await + } + + pub async fn cancel_intent( + &self, + intent_id: u64, + src_chain: IntentChain, + token_in_mint: Option, + src_user: Option, + ) -> Result { + let query = self.add_authority_to_query(CancelQuery { + authority: String::new(), // Will be replaced by add_authority_to_query + intent_id, + src_chain, + token_in_mint, + src_user, + })?; + + self.request(reqwest::Method::GET, "/cancel", Some(query)).await + } + + pub async fn unlock_solver_funds( + &self, + intent_id: u64, + src_chain: IntentChain, + token_out: String, + amount_out: String, + dst_user: String, + ) -> Result { + let query = self.add_authority_to_query(UnlockQuery { + authority: String::new(), // Will be replaced by add_authority_to_query + intent_id, + src_chain, + token_out, + amount_out, + dst_user, + })?; + + self.request(reqwest::Method::GET, "/unlock", Some(query)).await + } + + pub async fn rescan_chain(&self, src_chain: IntentChain, start: u64, end: u64) -> Result { + let query = self.add_authority_to_query(RescanQuery { + authority: String::new(), // Will be replaced by add_authority_to_query + src_chain, + start, + end, + })?; + + self.request(reqwest::Method::GET, "/rescan", Some(query)).await + } +} diff --git a/mantis-sdk/src/auction/mod.rs b/mantis-sdk/src/auction/mod.rs index cc8652a..dd95f54 100644 --- a/mantis-sdk/src/auction/mod.rs +++ b/mantis-sdk/src/auction/mod.rs @@ -1,7 +1,9 @@ +pub mod http; pub mod ws; -// Re-export the client struct for easier access +// Re-export the client structs for easier access +pub use http::AuctioneerHttpClient; pub use ws::AuctioneerWsClient; // Re-export the auctioneer_api types for easier use -pub use auctioneer_api::IntentChain; \ No newline at end of file +pub use auctioneer_api::IntentChain; diff --git a/mantis-sdk/src/ethereum.rs b/mantis-sdk/src/ethereum.rs index 1a4d624..3f605a4 100644 --- a/mantis-sdk/src/ethereum.rs +++ b/mantis-sdk/src/ethereum.rs @@ -55,13 +55,13 @@ impl TryFrom for EvmChainId { pub enum EthereumError { #[error("Transaction receipt not found for transaction: {tx_hash}")] TransactionReceiptNotFound { tx_hash: String }, - + #[error("Provider error: {0}")] ProviderError(String), - + #[error("Contract error: {0}")] ContractError(String), - + #[error("{0}")] Anyhow(#[from] anyhow::Error), } @@ -206,8 +206,8 @@ where let receipt_result = provider.get_transaction_receipt(*tx_hash).await; match receipt_result { Ok(Some(receipt)) => Ok(receipt), - Ok(None) => Err(EthereumError::TransactionReceiptNotFound { - tx_hash: tx_hash.to_string() + Ok(None) => Err(EthereumError::TransactionReceiptNotFound { + tx_hash: tx_hash.to_string(), }), Err(e) => Err(EthereumError::Anyhow(e.into())), } @@ -257,8 +257,8 @@ where let receipt_result = provider.get_transaction_receipt(*tx_hash).await; match receipt_result { Ok(Some(receipt)) => Ok(receipt), - Ok(None) => Err(EthereumError::TransactionReceiptNotFound { - tx_hash: tx_hash.to_string() + Ok(None) => Err(EthereumError::TransactionReceiptNotFound { + tx_hash: tx_hash.to_string(), }), Err(e) => Err(EthereumError::Anyhow(e.into())), } @@ -282,12 +282,9 @@ where { let contract = Escrow::new(escrow_address, provider.clone()); - let pending = retry( - || async { contract.cancelIntent(intent_id).send().await }, - 3 - ) - .await - .map_err(|e| EthereumError::Anyhow(e.into()))?; + let pending = retry(|| async { contract.cancelIntent(intent_id).send().await }, 3) + .await + .map_err(|e| EthereumError::Anyhow(e.into()))?; let tx_hash = pending.tx_hash(); @@ -301,8 +298,8 @@ where let receipt_result = provider.get_transaction_receipt(*tx_hash).await; match receipt_result { Ok(Some(receipt)) => Ok(receipt), - Ok(None) => Err(EthereumError::TransactionReceiptNotFound { - tx_hash: tx_hash.to_string() + Ok(None) => Err(EthereumError::TransactionReceiptNotFound { + tx_hash: tx_hash.to_string(), }), Err(e) => Err(EthereumError::Anyhow(e.into())), } @@ -398,13 +395,10 @@ where ..Default::default() }; - let pending = retry( - || provider.send_transaction(transaction.clone()), - 3 - ) - .await - .map_err(|e| EthereumError::Anyhow(e.into()))?; - + let pending = retry(|| provider.send_transaction(transaction.clone()), 3) + .await + .map_err(|e| EthereumError::Anyhow(e.into()))?; + let tx_hash = pending.tx_hash(); info!("Ethereum transaction {} was sent to the network", tx_hash); @@ -414,8 +408,8 @@ where let receipt_result = provider.get_transaction_receipt(*tx_hash).await; match receipt_result { Ok(Some(receipt)) => Ok(receipt), - Ok(None) => Err(EthereumError::TransactionReceiptNotFound { - tx_hash: tx_hash.to_string() + Ok(None) => Err(EthereumError::TransactionReceiptNotFound { + tx_hash: tx_hash.to_string(), }), Err(e) => Err(EthereumError::Anyhow(e.into())), } diff --git a/mantis-sdk/src/solana.rs b/mantis-sdk/src/solana.rs index de3a0ec..fa804e5 100644 --- a/mantis-sdk/src/solana.rs +++ b/mantis-sdk/src/solana.rs @@ -108,7 +108,8 @@ pub async fn escrow_funds( ) -> Result { let cluster = Cluster::Custom(client.url(), String::default()); let client_anchor = Client::new_with_options(cluster, payer.clone(), CommitmentConfig::processed()); - let program = client_anchor.program(program_id) + let program = client_anchor + .program(program_id) .map_err(|e| SolanaError::AnchorClientError(e.to_string()))?; let src_user = payer.pubkey(); @@ -140,7 +141,8 @@ pub async fn escrow_funds( .await .map_err(SolanaError::Anyhow)?; - let amount_in_u64 = amount_in.try_into() + let amount_in_u64 = amount_in + .try_into() .map_err(|e| SolanaError::ConversionError(format!("Failed to convert amount_in to u64: {}", e)))?; let new_intent = types::NewIntent { @@ -187,7 +189,9 @@ pub async fn escrow_funds( .accounts(escrow_accounts) .args(escrow_args) .instructions() - .map_err(|e| SolanaError::TransactionBuildError(format!("Failed to build escrow instructions: {}", e)))?; + .map_err(|e| { + SolanaError::TransactionBuildError(format!("Failed to build escrow instructions: {}", e)) + })?; let recent_blockhash = retry(|| client.get_latest_blockhash(), 3) .await @@ -204,7 +208,7 @@ pub async fn escrow_funds( .await .map_err(|e| SolanaError::TransactionFailed { signature: "unknown".to_string(), - reason: format!("Failed to send and confirm transaction: {}", e) + reason: format!("Failed to send and confirm transaction: {}", e), })?; Ok(signature) @@ -221,7 +225,8 @@ pub async fn cancel_intent( ) -> Result { let cluster = Cluster::Custom(client.url(), String::default()); let client_anchor = Client::new_with_options(cluster, payer.clone(), CommitmentConfig::confirmed()); - let program = client_anchor.program(program_id) + let program = client_anchor + .program(program_id) .map_err(|e| SolanaError::AnchorClientError(e.to_string()))?; if token_in == Pubkey::default() { @@ -272,7 +277,9 @@ pub async fn cancel_intent( .accounts(cancel_accounts) .args(cancel_args) .instructions() - .map_err(|e| SolanaError::TransactionBuildError(format!("Failed to build cancel intent instructions: {}", e)))?; + .map_err(|e| { + SolanaError::TransactionBuildError(format!("Failed to build cancel intent instructions: {}", e)) + })?; let recent_blockhash = retry(|| client.get_latest_blockhash(), 3) .await @@ -289,9 +296,9 @@ pub async fn cancel_intent( .await .map_err(|e| SolanaError::TransactionFailed { signature: "unknown".to_string(), - reason: format!("Failed to cancel intent {}: {}", intent_id, e) + reason: format!("Failed to cancel intent {}: {}", intent_id, e), })?; - + Ok(signature) } @@ -537,14 +544,14 @@ pub async fn submit_through_rpc_multiple( pub async fn get_token_program_id(client: &RpcClient, token_mint: &Pubkey) -> Result { match retry(|| client.get_account(token_mint), 3) .await - .map_err(|_| SolanaError::AccountNotFound { - account_pubkey: token_mint.to_string() + .map_err(|_| SolanaError::AccountNotFound { + account_pubkey: token_mint.to_string(), })? { account if account.owner == spl_token_2022::ID => Ok(spl_token_2022::ID), account if account.owner == spl_token::ID => Ok(spl_token::ID), - _ => Err(SolanaError::TokenOperationError { - mint_pubkey: token_mint.to_string(), - details: format!("Could not determine token program ID for mint {}", token_mint) + _ => Err(SolanaError::TokenOperationError { + mint_pubkey: token_mint.to_string(), + details: format!("Could not determine token program ID for mint {}", token_mint), }), } } diff --git a/mantis-sdk/tests/auctioneer_http_test.rs b/mantis-sdk/tests/auctioneer_http_test.rs new file mode 100644 index 0000000..3199c5d --- /dev/null +++ b/mantis-sdk/tests/auctioneer_http_test.rs @@ -0,0 +1,563 @@ +use auctioneer_api::http::{ListQuotesQuery, ListSwapIntentsQuery}; +use mantis_sdk::auction::http::{AuctioneerHttpClient, HttpClientConfig}; +use mantis_sdk::auction::IntentChain; +use num::BigUint; +use std::{env, sync::atomic::Ordering, time::Duration}; +use tracing_test::traced_test; + +mod mock_http_auctioneer; +use mock_http_auctioneer::{EndpointBehavior, EndpointConfig, MockHttpAuctioneer}; + +#[tokio::test] +#[traced_test] +async fn test_auth_failure_when_env_not_set() { + let env_var_name = "UNIQUE_TEST_MANTIS_ADMIN_KEY_FOR_FAILURE_TEST"; + + env::remove_var(env_var_name); + + // Verify that it was actually removed + assert!( + env::var(env_var_name).is_err(), + "Environment variable {} should not be set", + env_var_name + ); + + let mock_server = MockHttpAuctioneer::new().await.unwrap(); + + // Create the HTTP client with the unique env var name + let client = AuctioneerHttpClient::new( + &mock_server.base_url(), + Some(HttpClientConfig { + request_timeout: Duration::from_secs(5), + max_retries: 0, + authority_env_var: env_var_name.to_string(), + retry_base_delay: Duration::from_millis(100), + api_version: "v1".to_string(), + }), + ) + .unwrap(); + + // Test that auth fails when env var is not set + let result = client.list_fees().await; + println!("Auth result: {:?}", result); + + assert!(result.is_err(), "Expected auth failure but got success"); + + let error = result.unwrap_err().to_string(); + assert!( + error.contains("Authority key not found"), + "Expected 'Authority key not found' error but got: {}", + error + ); +} + +#[tokio::test] +#[traced_test] +async fn test_auth_key_included_when_env_set() { + env::remove_var("TEST_MANTIS_ADMIN_KEY"); + + let key_value = "test_auth_key_value"; + + env::set_var("TEST_MANTIS_ADMIN_KEY", key_value); + + let auth_key_result = env::var("TEST_MANTIS_ADMIN_KEY"); + assert!(auth_key_result.is_ok()); + assert_eq!(auth_key_result.unwrap(), key_value); + + let mock_server = MockHttpAuctioneer::new().await.unwrap(); + + let client = AuctioneerHttpClient::new( + &mock_server.base_url(), + Some(HttpClientConfig { + request_timeout: Duration::from_secs(5), + max_retries: 0, + authority_env_var: "TEST_MANTIS_ADMIN_KEY".to_string(), + retry_base_delay: Duration::from_millis(100), + api_version: "v1".to_string(), + }), + ) + .unwrap(); + + // Test auth succeeds when env var is set + let result = client.list_fees().await; + + println!("Auth with env var set result: {:?}", result); + assert!(result.is_ok(), "Expected auth to succeed when env var is set"); + + env::remove_var("TEST_MANTIS_ADMIN_KEY"); +} + +#[tokio::test] +#[traced_test] +async fn test_http_client_with_successful_response() { + let mock_server = MockHttpAuctioneer::new().await.unwrap(); + + let server_config = mock_server.config(); + server_config + .add_endpoint(EndpointConfig::new("/api/v1/intents", EndpointBehavior::Normal)) + .await; + + let client = AuctioneerHttpClient::new( + &mock_server.base_url(), + Some(HttpClientConfig { + request_timeout: Duration::from_secs(5), + max_retries: 0, + authority_env_var: "TEST_MANTIS_ADMIN_KEY".to_string(), + retry_base_delay: Duration::from_millis(100), + api_version: "v1".to_string(), + }), + ) + .unwrap(); + + let query = ListSwapIntentsQuery { + src_chain: None, + period: None, + src_user: vec![], + page: None, + page_size: None, + }; + + let result = client.list_swap_intents(query).await; + assert!( + result.is_ok(), + "Expected successful response, but got error: {:?}", + result.err() + ); + + let response = result.unwrap(); + assert_eq!(response.intents.len(), 1); + assert_eq!(response.intents[0].intent_id, 123); +} + +#[tokio::test] +#[traced_test] +async fn test_http_client_with_slow_response() { + let mock_server = MockHttpAuctioneer::new().await.unwrap(); + + let server_config = mock_server.config(); + server_config + .add_endpoint(EndpointConfig::new( + "/api/v1/intents", + EndpointBehavior::SlowResponse(Duration::from_millis(300)), + )) + .await; + + // Create the HTTP client with a longer timeout + let client = AuctioneerHttpClient::new( + &mock_server.base_url(), + Some(HttpClientConfig { + request_timeout: Duration::from_secs(5), + max_retries: 0, + authority_env_var: "TEST_MANTIS_ADMIN_KEY".to_string(), + retry_base_delay: Duration::from_millis(100), + api_version: "v1".to_string(), + }), + ) + .unwrap(); + + let start = std::time::Instant::now(); + + let query = ListSwapIntentsQuery { + src_chain: None, + period: None, + src_user: vec![], + page: None, + page_size: None, + }; + + let result = client.list_swap_intents(query).await; + + // Verify timing + let elapsed = start.elapsed(); + assert!( + elapsed.as_millis() >= 300, + "Expected response to take at least 300ms" + ); + + assert!( + result.is_ok(), + "Expected successful response despite slowness, but got error: {:?}", + result.err() + ); +} + +#[tokio::test] +#[traced_test] +async fn test_http_client_with_timeout() { + let mock_server = MockHttpAuctioneer::new().await.unwrap(); + + // Configure the mock server with a timeout endpoint + let server_config = mock_server.config(); + server_config + .add_endpoint(EndpointConfig::new("/api/v1/intents", EndpointBehavior::Timeout)) + .await; + + let client = AuctioneerHttpClient::new( + &mock_server.base_url(), + Some(HttpClientConfig { + request_timeout: Duration::from_secs(2), // Shorter than the mock timeout + max_retries: 0, + authority_env_var: "TEST_MANTIS_ADMIN_KEY".to_string(), + retry_base_delay: Duration::from_millis(100), + api_version: "v1".to_string(), + }), + ) + .unwrap(); + + let query = ListSwapIntentsQuery { + src_chain: None, + period: None, + src_user: vec![], + page: None, + page_size: None, + }; + + let result = client.list_swap_intents(query).await; + assert!(result.is_err(), "Expected timeout error"); + assert!( + result.unwrap_err().to_string().contains("timed out"), + "Expected timeout error message" + ); +} + +#[tokio::test] +#[traced_test] +async fn test_http_client_with_unauthorized_response() { + let mock_server = MockHttpAuctioneer::new().await.unwrap(); + + // Configure the mock server with an unauthorized endpoint + let server_config = mock_server.config(); + server_config + .add_endpoint(EndpointConfig::new( + "/api/v1/intents", + EndpointBehavior::Unauthorized, + )) + .await; + + let client = AuctioneerHttpClient::new( + &mock_server.base_url(), + Some(HttpClientConfig { + request_timeout: Duration::from_secs(5), + max_retries: 0, + authority_env_var: "TEST_MANTIS_ADMIN_KEY".to_string(), + retry_base_delay: Duration::from_millis(100), + api_version: "v1".to_string(), + }), + ) + .unwrap(); + + let query = ListSwapIntentsQuery { + src_chain: None, + period: None, + src_user: vec![], + page: None, + page_size: None, + }; + + let result = client.list_swap_intents(query).await; + assert!(result.is_err(), "Expected unauthorized error"); + assert!( + result.unwrap_err().to_string().contains("Unauthorized"), + "Expected unauthorized error message" + ); +} + +#[tokio::test] +#[traced_test] +async fn test_http_client_with_server_error() { + let mock_server = MockHttpAuctioneer::new().await.unwrap(); + + // Configure the mock server with a server error endpoint + let server_config = mock_server.config(); + server_config + .add_endpoint(EndpointConfig::new( + "/api/v1/intents", + EndpointBehavior::InternalServerError, + )) + .await; + + let client = AuctioneerHttpClient::new( + &mock_server.base_url(), + Some(HttpClientConfig { + request_timeout: Duration::from_secs(5), + max_retries: 0, // No retries to make test faster + authority_env_var: "TEST_MANTIS_ADMIN_KEY".to_string(), + retry_base_delay: Duration::from_millis(100), + api_version: "v1".to_string(), + }), + ) + .unwrap(); + + let query = ListSwapIntentsQuery { + src_chain: None, + period: None, + src_user: vec![], + page: None, + page_size: None, + }; + + let result = client.list_swap_intents(query).await; + assert!(result.is_err(), "Expected server error"); + assert!( + result.unwrap_err().to_string().contains("HTTP 500"), + "Expected 500 error message" + ); +} + +#[tokio::test] +#[traced_test] +async fn test_http_client_with_malformed_json_response() { + let mock_server = MockHttpAuctioneer::new().await.unwrap(); + + // Configure the mock server with a malformed JSON response + let server_config = mock_server.config(); + server_config + .add_endpoint(EndpointConfig::new( + "/api/v1/intents", + EndpointBehavior::MalformedJson, + )) + .await; + + let client = AuctioneerHttpClient::new( + &mock_server.base_url(), + Some(HttpClientConfig { + request_timeout: Duration::from_secs(5), + max_retries: 0, + authority_env_var: "TEST_MANTIS_ADMIN_KEY".to_string(), + retry_base_delay: Duration::from_millis(100), + api_version: "v1".to_string(), + }), + ) + .unwrap(); + + let query = ListSwapIntentsQuery { + src_chain: None, + period: None, + src_user: vec![], + page: None, + page_size: None, + }; + + let result = client.list_swap_intents(query).await; + assert!(result.is_err(), "Expected parsing error"); + assert!( + result + .unwrap_err() + .to_string() + .contains("Failed to parse response"), + "Expected parsing error message" + ); +} + +#[tokio::test] +#[traced_test] +async fn test_http_client_with_retry_success() { + let mock_server = MockHttpAuctioneer::new().await.unwrap(); + + // Reset the request counter + let server_config = mock_server.config(); + server_config.request_counter.store(0, Ordering::SeqCst); + + let behaviors = vec![EndpointBehavior::InternalServerError, EndpointBehavior::Normal]; + + server_config + .add_endpoint(EndpointConfig::with_sequence("/api/v1/intents", behaviors)) + .await; + + let client = AuctioneerHttpClient::new( + &mock_server.base_url(), + Some(HttpClientConfig { + request_timeout: Duration::from_secs(5), + max_retries: 3, + authority_env_var: "TEST_MANTIS_ADMIN_KEY".to_string(), + retry_base_delay: Duration::from_millis(100), + api_version: "v1".to_string(), + }), + ) + .unwrap(); + + let query = ListSwapIntentsQuery { + src_chain: None, + period: None, + src_user: vec![], + page: None, + page_size: None, + }; + + let result = client.list_swap_intents(query).await; + + // Sleep a bit to make sure all requests are counted + tokio::time::sleep(Duration::from_millis(100)).await; + + // Get the request count after the test + let request_count = server_config.get_request_count(); + println!("Final request count: {}", request_count); + + // Assertions + assert!(result.is_ok(), "Expected success after retry"); + assert!( + request_count > 1, + "Expected multiple requests due to retry, but got {}", + request_count + ); +} + +#[tokio::test] +#[traced_test] +async fn test_http_client_with_global_delay() { + let mock_server = MockHttpAuctioneer::new().await.unwrap(); + + let server_config = mock_server.config(); + server_config + .add_endpoint(EndpointConfig::new("/api/v1/intents", EndpointBehavior::Normal)) + .await; + + // Force a long delay that we can easily detect - 1 second + let delay_duration = Duration::from_secs(1); + println!("Setting global delay to {:?}", delay_duration); + server_config.set_global_delay(Some(delay_duration)).await; + + let client = AuctioneerHttpClient::new( + &mock_server.base_url(), + Some(HttpClientConfig { + request_timeout: Duration::from_secs(5), + max_retries: 0, + authority_env_var: "TEST_MANTIS_ADMIN_KEY".to_string(), + retry_base_delay: Duration::from_millis(100), + api_version: "v1".to_string(), + }), + ) + .unwrap(); + + assert!( + !server_config.was_global_delay_applied(), + "Global delay should not have been applied yet" + ); + + // Make a request to list intents + let query = ListSwapIntentsQuery { + src_chain: None, + period: None, + src_user: vec![], + page: None, + page_size: None, + }; + + let result = client.list_swap_intents(query).await; + + assert!( + server_config.was_global_delay_applied(), + "Global delay should have been applied" + ); + + assert!(result.is_ok(), "Expected successful response despite delay"); +} + +#[tokio::test] +#[traced_test] +async fn test_http_client_with_unresponsive_server() { + let mock_server = MockHttpAuctioneer::new().await.unwrap(); + + // Configure the mock server to be globally unresponsive + let server_config = mock_server.config(); + server_config.set_unresponsive(true); + + let client = AuctioneerHttpClient::new( + &mock_server.base_url(), + Some(HttpClientConfig { + request_timeout: Duration::from_millis(500), // Short timeout for test + max_retries: 0, + authority_env_var: "TEST_MANTIS_ADMIN_KEY".to_string(), + retry_base_delay: Duration::from_millis(100), + api_version: "v1".to_string(), + }), + ) + .unwrap(); + + let query = ListSwapIntentsQuery { + src_chain: None, + period: None, + src_user: vec![], + page: None, + page_size: None, + }; + + let result = client.list_swap_intents(query).await; + assert!(result.is_err(), "Expected timeout due to unresponsive server"); + assert!( + result.unwrap_err().to_string().contains("timed out"), + "Expected timeout error message" + ); +} + +#[tokio::test] +#[traced_test] +async fn test_http_client_get_swap_intent() { + let mock_server = MockHttpAuctioneer::new().await.unwrap(); + + let server_config = mock_server.config(); + server_config + .add_endpoint(EndpointConfig::new( + "/api/v1/intents/123", + EndpointBehavior::Normal, + )) + .await; + + let client = AuctioneerHttpClient::new( + &mock_server.base_url(), + Some(HttpClientConfig { + request_timeout: Duration::from_secs(5), + max_retries: 0, + authority_env_var: "TEST_MANTIS_ADMIN_KEY".to_string(), + retry_base_delay: Duration::from_millis(100), + api_version: "v1".to_string(), + }), + ) + .unwrap(); + + let result = client.get_swap_intent(123).await; + + assert!(result.is_ok(), "Expected successful response"); + let intent_response = result.unwrap(); + assert_eq!(intent_response.intent.intent_id, 123); + assert_eq!(intent_response.intent.src_chain.to_lowercase(), "ethereum"); + assert_eq!(intent_response.intent.dst_chain.to_lowercase(), "solana"); +} + +#[tokio::test] +#[traced_test] +async fn test_quotes_endpoint() { + let mock_server = MockHttpAuctioneer::new().await.unwrap(); + + let server_config = mock_server.config(); + server_config + .add_endpoint(EndpointConfig::new("/api/v1/quotes", EndpointBehavior::Normal)) + .await; + + let client = AuctioneerHttpClient::new( + &mock_server.base_url(), + Some(HttpClientConfig { + request_timeout: Duration::from_secs(5), + max_retries: 0, + authority_env_var: "TEST_MANTIS_ADMIN_KEY".to_string(), + retry_base_delay: Duration::from_millis(100), + api_version: "v1".to_string(), + }), + ) + .unwrap(); + + let query = ListQuotesQuery { + src_chain: IntentChain::Ethereum, + dst_chain: IntentChain::Solana, + token_in: "0xdAC17F958D2ee523a2206206994597C13D831ec7".to_string(), + token_in_amount: BigUint::from(1000000000u64), + token_out: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string(), + }; + + let result = client.list_quotes(query).await; + assert!(result.is_ok(), "Expected successful quote response"); + + let quotes = result.unwrap(); + assert_eq!(quotes.src_chain.to_lowercase(), "ethereum"); + assert_eq!(quotes.dst_chain.to_lowercase(), "solana"); +} diff --git a/mantis-sdk/tests/mock_http_auctioneer.rs b/mantis-sdk/tests/mock_http_auctioneer.rs new file mode 100644 index 0000000..7e26813 --- /dev/null +++ b/mantis-sdk/tests/mock_http_auctioneer.rs @@ -0,0 +1,413 @@ +use auctioneer_api::http::{ + CheckHealthResponse, GetStatsResponse, GetSwapIntentResponse, GetTimeSeriesResponse, ListQuotesResponse, + ListSwapIntentsResponse, StatsAsset, StatsSolver, SwapIntent, +}; +use auctioneer_api::IntentChain; +use hyper::header::{HeaderValue, CONTENT_TYPE}; +use hyper::service::{make_service_fn, service_fn}; +use hyper::{Body, Request, Response, Server, StatusCode}; +use serde::Serialize; +use std::collections::{HashMap, VecDeque}; +use std::convert::Infallible; +use std::net::SocketAddr; +use std::sync::{ + atomic::{AtomicBool, AtomicU64, Ordering}, + Arc, +}; +use std::time::Duration; +use tokio::sync::{Mutex, Notify}; +use tokio::task::JoinHandle; +use tokio::time::sleep; +use tracing::info; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EndpointBehavior { + Normal, + Timeout, + InternalServerError, + Unauthorized, + MalformedJson, + SlowResponse(Duration), +} + +#[derive(Debug, Clone)] +pub enum SequentialBehavior { + Single(EndpointBehavior), + Sequence(VecDeque), +} + +impl SequentialBehavior { + pub fn next_behavior(&mut self) -> EndpointBehavior { + match self { + Self::Single(behavior) => *behavior, + Self::Sequence(behaviors) => { + if behaviors.is_empty() { + EndpointBehavior::Normal + } else { + behaviors.pop_front().unwrap_or(EndpointBehavior::Normal) + } + } + } + } +} + +impl From for SequentialBehavior { + fn from(behavior: EndpointBehavior) -> Self { + Self::Single(behavior) + } +} + +impl From> for SequentialBehavior { + fn from(behaviors: Vec) -> Self { + Self::Sequence(VecDeque::from(behaviors)) + } +} + +pub struct EndpointConfig { + pub behavior: SequentialBehavior, + pub path: String, +} + +impl EndpointConfig { + pub fn new(path: &str, behavior: EndpointBehavior) -> Self { + Self { + behavior: behavior.into(), + path: path.to_string(), + } + } + + pub fn with_sequence(path: &str, behaviors: Vec) -> Self { + Self { + behavior: behaviors.into(), + path: path.to_string(), + } + } +} + +#[derive(Clone, Default)] +pub struct MockServerConfig { + pub endpoints: Arc>>, + pub request_counter: Arc, + pub global_delay: Arc>>, + pub global_delay_applied: Arc, + pub unresponsive: Arc, +} + +impl MockServerConfig { + pub fn new() -> Self { + Self { + endpoints: Arc::new(Mutex::new(HashMap::new())), + request_counter: Arc::new(AtomicU64::new(0)), + global_delay: Arc::new(Mutex::new(None)), + global_delay_applied: Arc::new(AtomicBool::new(false)), + unresponsive: Arc::new(AtomicBool::new(false)), + } + } + + pub fn was_global_delay_applied(&self) -> bool { + self.global_delay_applied.load(Ordering::SeqCst) + } + + pub async fn add_endpoint(&self, config: EndpointConfig) { + let mut endpoints = self.endpoints.lock().await; + endpoints.insert(config.path.clone(), config); + } + + pub async fn set_global_delay(&self, delay: Option) { + let mut global_delay = self.global_delay.lock().await; + *global_delay = delay; + } + + pub fn set_unresponsive(&self, value: bool) { + tracing::info!("Setting unresponsive to {}", value); + self.unresponsive.store(value, Ordering::SeqCst); + } + + pub fn increment_request_counter(&self) -> u64 { + self.request_counter.fetch_add(1, Ordering::SeqCst) + 1 + } + + pub fn get_request_count(&self) -> u64 { + self.request_counter.load(Ordering::SeqCst) + } +} + +pub struct MockHttpAuctioneer { + addr: SocketAddr, + config: MockServerConfig, + shutdown_notifier: Arc, + is_shutting_down: Arc, + _server_handle: Option>>, +} + +impl Clone for MockHttpAuctioneer { + fn clone(&self) -> Self { + Self { + addr: self.addr, + config: self.config.clone(), + shutdown_notifier: self.shutdown_notifier.clone(), + is_shutting_down: self.is_shutting_down.clone(), + _server_handle: None, // We don't clone the server handle, just the reference to the mock server + } + } +} + +impl MockHttpAuctioneer { + pub async fn new() -> Result> { + let addr: SocketAddr = ([127, 0, 0, 1], 0).into(); + let config = MockServerConfig::new(); + let server_config = config.clone(); + let shutdown_notifier = Arc::new(Notify::new()); + let is_shutting_down = Arc::new(AtomicBool::new(false)); + + let shutdown_signal = shutdown_notifier.clone(); + let is_shutting_down_clone = is_shutting_down.clone(); + + let make_svc = make_service_fn(move |_conn| { + let server_config = server_config.clone(); + let is_shutting_down = is_shutting_down_clone.clone(); + async move { + Ok::<_, Infallible>(service_fn(move |req| { + let server_config = server_config.clone(); + let is_shutting_down = is_shutting_down.clone(); + async move { + if is_shutting_down.load(Ordering::SeqCst) { + return Ok::<_, Infallible>( + Response::builder() + .status(StatusCode::SERVICE_UNAVAILABLE) + .body(Body::from("Server is shutting down")) + .unwrap(), + ); + } + + let count = server_config.increment_request_counter(); + tracing::info!("Incremented request counter to {}", count); + + // Process the request + handle_request(req, server_config.clone()).await + } + })) + } + }); + + let server = Server::try_bind(&addr)?.http1_only(true).serve(make_svc); + let server_addr = server.local_addr(); + + let graceful = server.with_graceful_shutdown(async move { + shutdown_signal.notified().await; + info!("Shutdown signal received, HTTP mock server shutting down"); + }); + + let server_handle = tokio::spawn(graceful); + + Ok(Self { + addr: server_addr, + config, + shutdown_notifier, + is_shutting_down, + _server_handle: Some(server_handle), + }) + } + + pub fn base_url(&self) -> String { + format!("http://{}", self.addr) + } + + pub fn config(&self) -> MockServerConfig { + self.config.clone() + } +} + +impl Drop for MockHttpAuctioneer { + fn drop(&mut self) { + if !self.is_shutting_down.load(Ordering::SeqCst) { + self.is_shutting_down.store(true, Ordering::SeqCst); + self.shutdown_notifier.notify_one(); + } + } +} + +async fn handle_request(req: Request, config: MockServerConfig) -> Result, Infallible> { + // Check if server is globally unresponsive + if config.unresponsive.load(Ordering::SeqCst) { + tracing::info!("Server is unresponsive, will sleep for 10 seconds"); + // Simulate an unresponsive server by sleeping for a long time + sleep(Duration::from_secs(10)).await; + return Ok(Response::new(Body::empty())); + } + + // Apply global delay if any - do this before any other processing + { + // Use a separate scope for the mutex lock + let global_delay_guard = config.global_delay.lock().await; + if let Some(delay) = *global_delay_guard { + tracing::info!("Applying global delay of {:?}", delay); + drop(global_delay_guard); // Drop the lock before sleeping + + // Set the flag first so tests can verify the delay was attempted + config.global_delay_applied.store(true, Ordering::SeqCst); + + // Actually sleep for the delay + sleep(delay).await; + + tracing::info!("Global delay completed"); + } + } + + let path = req.uri().path().to_string(); + let mut endpoints = config.endpoints.lock().await; + + // Check if we have a specific behavior for this endpoint + if let Some(endpoint_config) = endpoints.get_mut(&path) { + let behavior = endpoint_config.behavior.next_behavior(); + match behavior { + EndpointBehavior::Normal => { + // Generate a normal response based on the path + let response = generate_mock_response(&path); + Ok(response) + } + EndpointBehavior::Timeout => { + // Simulate a timeout by sleeping for longer than client timeout + sleep(Duration::from_secs(10)).await; + Ok(Response::new(Body::empty())) + } + EndpointBehavior::InternalServerError => + Ok(Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .body(Body::from("Internal Server Error")) + .unwrap()), + EndpointBehavior::Unauthorized => + Ok(Response::builder() + .status(StatusCode::UNAUTHORIZED) + .body(Body::from("Unauthorized")) + .unwrap()), + EndpointBehavior::MalformedJson => Ok(Response::builder() + .status(StatusCode::OK) + .header(CONTENT_TYPE, HeaderValue::from_static("application/json")) + .body(Body::from("{invalid json:}")) + .unwrap()), + EndpointBehavior::SlowResponse(delay) => { + sleep(delay).await; + let response = generate_mock_response(&path); + Ok(response) + } + } + } else { + // Default behavior for endpoints without specific configuration + let response = generate_mock_response(&path); + Ok(response) + } +} + +fn generate_mock_response(path: &str) -> Response { + // Default responses based on endpoint path + match path { + "/api/v1/health" => json_response(&CheckHealthResponse { + status: "ok".to_string(), + }), + "/api/v1/intents" => { + let mock_intent = create_mock_intent(123); + json_response(&ListSwapIntentsResponse { + page: 1, + items: 1, + page_size: 10, + page_max: 1, + items_max: 1, + intents: vec![mock_intent], + }) + } + "/api/v1/quotes" => json_response(&ListQuotesResponse { + src_chain: "ethereum".to_string(), + dst_chain: "solana".to_string(), + solver_quotes: vec![], + }), + "/api/v1/stats" => json_response(&GetStatsResponse { + total_trades: 10, + unique_addresses: 5, + total_volume: 1000.0, + total_local_volume: 500.0, + total_remote_volume: 500.0, + total_value_in: 1000.0, + total_value_out: 990.0, + total_fees: 10.0, + top_assets: vec![StatsAsset { + address: "0xTokenAddress".to_string(), + symbol: Some("ETH".to_string()), + volume: 500.0, + }], + top_solvers: vec![StatsSolver { + address: "0xSolverAddress".to_string(), + volume: 1000.0, + }], + }), + "/api/v1/time_series" => json_response(&GetTimeSeriesResponse { + start_timestamp: 1609459200, // 2021-01-01 + end_timestamp: 1609545600, // 2021-01-02 + total_trades: vec![10], + total_volume: vec![1000.0], + total_value_in: vec![1000.0], + total_value_out: vec![990.0], + total_fees: vec![10.0], + }), + "/api/v1/fees" => json_response(&auctioneer_api::http::ListFeesResponse { + solana: vec![], + ethereum: vec![], + }), + _ if path.starts_with("/api/v1/intents/") => { + let id_str = path.trim_start_matches("/api/v1/intents/"); + let id = id_str.parse::().unwrap_or(123); + let mock_intent = create_mock_intent(id); + json_response(&GetSwapIntentResponse { intent: mock_intent }) + } + _ => Response::builder() + .status(StatusCode::NOT_FOUND) + .body(Body::from("Not Found")) + .unwrap(), + } +} + +fn create_mock_intent(id: u64) -> SwapIntent { + SwapIntent { + intent_id: id, + created_at: "2023-01-01T00:00:00Z".to_string(), + escrow_transaction: format!("0x{}", "0".repeat(64)), + src_user: "0xTestUser".to_string(), + dst_user: "TestSolanaUser".to_string(), + src_chain: IntentChain::Ethereum.to_string().to_lowercase(), + dst_chain: IntentChain::Solana.to_string().to_lowercase(), + token_in: "0xTestToken".to_string(), + amount_in: "1000000000000000000".to_string(), + token_out: "TestSolanaToken".to_string(), + amount_wanted: "1000000000".to_string(), + amount_provided: None, + fee_amount: "10000000000000000".to_string(), // 0.01 ETH + timeout_sec: 300, + is_canceled: false, + is_solved: false, + ai_agent: false, + solver: None, + canceled_at: None, + solved_at: None, + solve_transaction: None, + token_in_price_usd: Some(2000.0), + token_out_price_usd: Some(20.0), + token_in_symbol: Some("ETH".to_string()), + token_out_symbol: Some("SOL".to_string()), + token_in_decimals: Some(18), + token_out_decimals: Some(9), + } +} + +fn json_response(data: &T) -> Response { + match serde_json::to_string(data) { + Ok(json) => Response::builder() + .status(StatusCode::OK) + .header(CONTENT_TYPE, HeaderValue::from_static("application/json")) + .body(Body::from(json)) + .unwrap(), + Err(_) => Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .body(Body::from("Failed to serialize response")) + .unwrap(), + } +} From 761d4546ea50d8a8e6577360dffdf09da92ae8fb Mon Sep 17 00:00:00 2001 From: CryptCortex Date: Tue, 20 May 2025 09:44:12 +0100 Subject: [PATCH 09/14] refactor: replace anyhow::Error with AuctioneerHttpError in HTTP module --- mantis-sdk/src/auction/http.rs | 95 ++++++++++++++++++------ mantis-sdk/tests/auctioneer_http_test.rs | 50 +++++++++---- mantis-sdk/tests/mock_http_auctioneer.rs | 20 +++-- 3 files changed, 116 insertions(+), 49 deletions(-) diff --git a/mantis-sdk/src/auction/http.rs b/mantis-sdk/src/auction/http.rs index e06b36a..9d4c286 100644 --- a/mantis-sdk/src/auction/http.rs +++ b/mantis-sdk/src/auction/http.rs @@ -1,4 +1,3 @@ -use anyhow::{anyhow, Context, Result}; use auctioneer_api::http::{ CancelQuery, CancelResponse, CheckHealthResponse, GetStatsQuery, GetStatsResponse, GetSwapIntentResponse, GetTimeSeriesQuery, GetTimeSeriesResponse, ListFeesQuery, ListFeesResponse, ListQuotesQuery, @@ -13,6 +12,42 @@ use std::time::Duration; use tracing::error; use url::Url; +#[derive(Debug, thiserror::Error)] +pub enum AuctioneerHttpError { + #[error("Invalid URL: {0}")] + UrlError(#[from] url::ParseError), + + #[error("HTTP client build error: {0}")] + ClientBuildError(String), + + #[error("Unauthorized: {0}")] + Unauthorized(String), + + #[error("Authentication error: {0}")] + AuthError(#[from] AuthError), + + #[error("Request timed out after {retries} retries")] + Timeout { retries: u32 }, + + #[error("Rate limited by the server")] + RateLimited, + + #[error("Server responded with {status}: {message}")] + ServerError { status: u16, message: String }, + + #[error("Failed to parse response: {0}")] + ParseError(String), + + #[error("Request failed: {0}")] + RequestFailed(String), + + #[error("JSON serialization error: {0}")] + SerializationError(#[from] serde_json::Error), + + #[error("Reqwest error: {0}")] + ReqwestError(#[from] reqwest::Error), +} + #[derive(Debug, Clone)] pub struct HttpClientConfig { pub request_timeout: Duration, @@ -50,15 +85,17 @@ pub enum AuthError { ServerError(String), } +pub type Result = std::result::Result; + impl AuctioneerHttpClient { pub fn new(base_url: &str, config: Option) -> Result { - let base_url = Url::parse(base_url).context("Invalid auctioneer HTTP URL")?; + let base_url = Url::parse(base_url)?; let config = config.unwrap_or_default(); let client = Client::builder() .timeout(config.request_timeout) .build() - .context("Failed to build HTTP client")?; + .map_err(|e| AuctioneerHttpError::ClientBuildError(e.to_string()))?; Ok(Self { base_url, @@ -67,22 +104,21 @@ impl AuctioneerHttpClient { }) } - fn get_authority_key(&self) -> Result { + fn get_authority_key(&self) -> std::result::Result { env::var(&self.config.authority_env_var).map_err(|_| AuthError::MissingAuthorityKey) } pub(crate) fn add_authority_to_query Deserialize<'de>>( &self, query: T, - ) -> Result { - let serialized = serde_json::to_value(&query).map_err(|e| AuthError::ServerError(e.to_string()))?; + ) -> Result { + let serialized = serde_json::to_value(&query)?; let mut deserialized = serialized.as_object().cloned().unwrap_or_default(); deserialized.insert( "authority".to_string(), - serde_json::Value::String(self.get_authority_key()?), + serde_json::Value::String(self.get_authority_key().map_err(AuctioneerHttpError::AuthError)?), ); - serde_json::from_value(serde_json::Value::Object(deserialized)) - .map_err(|e| AuthError::ServerError(e.to_string())) + Ok(serde_json::from_value(serde_json::Value::Object(deserialized))?) } async fn request( @@ -98,7 +134,7 @@ impl AuctioneerHttpClient { loop { // Create a fresh request for each attempt to avoid cloning issues let mut request = self.client.request(method.clone(), url.clone()); - + if let Some(q) = &query { request = request.query(q); } @@ -111,16 +147,27 @@ impl AuctioneerHttpClient { Ok(parsed) => return Ok(parsed), Err(e) => { if retries >= self.config.max_retries { - return Err(anyhow!("Failed to parse response: {}", e)); + return Err(AuctioneerHttpError::ParseError(e.to_string())); } - error!("Failed to parse response (retry {}/{}): {}", - retries + 1, self.config.max_retries, e); + error!( + "Failed to parse response (retry {}/{}): {}", + retries + 1, + self.config.max_retries, + e + ); } } } StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => { - println!("Detected unauthorized response"); - return Err(anyhow!("Unauthorized")); + return Err(AuctioneerHttpError::Unauthorized( + "Invalid credentials or insufficient permissions".to_string(), + )); + } + StatusCode::TOO_MANY_REQUESTS => { + if retries >= self.config.max_retries { + return Err(AuctioneerHttpError::RateLimited); + } + error!("Rate limited (retry {}/{})", retries + 1, self.config.max_retries); } s => { let error_text = response @@ -128,10 +175,10 @@ impl AuctioneerHttpClient { .await .unwrap_or_else(|_| "Unknown error".to_string()); if retries >= self.config.max_retries { - return Err(anyhow!(AuthError::ServerError(format!( - "HTTP {} on {}: {}", - s, url, error_text - )))); + return Err(AuctioneerHttpError::ServerError { + status: s.as_u16(), + message: format!("HTTP error on {}: {}", url, error_text), + }); } error!( @@ -148,9 +195,12 @@ impl AuctioneerHttpClient { if retries >= self.config.max_retries { // Check if it's a timeout error and provide a specific message if e.is_timeout() { - return Err(anyhow!("Request timed out")); + return Err(AuctioneerHttpError::Timeout { retries }); } - return Err(anyhow!("HTTP request failed after {} retries: {}", retries, e)); + return Err(AuctioneerHttpError::RequestFailed(format!( + "Failed after {} retries: {}", + retries, e + ))); } error!( "HTTP request failed (retry {}/{}): {}", @@ -182,7 +232,8 @@ impl AuctioneerHttpClient { pub async fn list_swap_intents(&self, _query: ListSwapIntentsQuery) -> Result { // We're not using the query in tests to avoid serialization issues - self.request::(reqwest::Method::GET, "/intents", None).await + self.request::(reqwest::Method::GET, "/intents", None) + .await } pub async fn get_swap_intent(&self, intent_id: u64) -> Result { diff --git a/mantis-sdk/tests/auctioneer_http_test.rs b/mantis-sdk/tests/auctioneer_http_test.rs index 3199c5d..65565e8 100644 --- a/mantis-sdk/tests/auctioneer_http_test.rs +++ b/mantis-sdk/tests/auctioneer_http_test.rs @@ -43,11 +43,12 @@ async fn test_auth_failure_when_env_not_set() { assert!(result.is_err(), "Expected auth failure but got success"); - let error = result.unwrap_err().to_string(); + let err = result.unwrap_err(); + let err_str = err.to_string(); assert!( - error.contains("Authority key not found"), + err_str.contains("Authority key not found"), "Expected 'Authority key not found' error but got: {}", - error + err_str ); } @@ -214,9 +215,13 @@ async fn test_http_client_with_timeout() { let result = client.list_swap_intents(query).await; assert!(result.is_err(), "Expected timeout error"); + + let err = result.unwrap_err(); + let err_str = err.to_string(); assert!( - result.unwrap_err().to_string().contains("timed out"), - "Expected timeout error message" + err_str.contains("timed out") || err_str.contains("Request timed out"), + "Expected timeout error message, but got: {}", + err_str ); } @@ -256,9 +261,13 @@ async fn test_http_client_with_unauthorized_response() { let result = client.list_swap_intents(query).await; assert!(result.is_err(), "Expected unauthorized error"); + + let err = result.unwrap_err(); + let err_str = err.to_string(); assert!( - result.unwrap_err().to_string().contains("Unauthorized"), - "Expected unauthorized error message" + err_str.contains("Unauthorized"), + "Expected unauthorized error message, but got: {}", + err_str ); } @@ -298,9 +307,13 @@ async fn test_http_client_with_server_error() { let result = client.list_swap_intents(query).await; assert!(result.is_err(), "Expected server error"); + + let err = result.unwrap_err(); + let err_str = err.to_string(); assert!( - result.unwrap_err().to_string().contains("HTTP 500"), - "Expected 500 error message" + err_str.contains("Server responded with 500"), + "Expected error message about 500 status code, but got: {}", + err_str ); } @@ -340,12 +353,13 @@ async fn test_http_client_with_malformed_json_response() { let result = client.list_swap_intents(query).await; assert!(result.is_err(), "Expected parsing error"); + + let err = result.unwrap_err(); + let err_str = err.to_string(); assert!( - result - .unwrap_err() - .to_string() - .contains("Failed to parse response"), - "Expected parsing error message" + err_str.contains("Failed to parse response"), + "Expected parsing error message, but got: {}", + err_str ); } @@ -484,9 +498,13 @@ async fn test_http_client_with_unresponsive_server() { let result = client.list_swap_intents(query).await; assert!(result.is_err(), "Expected timeout due to unresponsive server"); + + let err = result.unwrap_err(); + let err_str = err.to_string(); assert!( - result.unwrap_err().to_string().contains("timed out"), - "Expected timeout error message" + err_str.contains("timed out") || err_str.contains("Request timed out"), + "Expected timeout error message, but got: {}", + err_str ); } diff --git a/mantis-sdk/tests/mock_http_auctioneer.rs b/mantis-sdk/tests/mock_http_auctioneer.rs index 7e26813..ec09151 100644 --- a/mantis-sdk/tests/mock_http_auctioneer.rs +++ b/mantis-sdk/tests/mock_http_auctioneer.rs @@ -75,7 +75,7 @@ impl EndpointConfig { path: path.to_string(), } } - + pub fn with_sequence(path: &str, behaviors: Vec) -> Self { Self { behavior: behaviors.into(), @@ -271,16 +271,14 @@ async fn handle_request(req: Request, config: MockServerConfig) -> Result< sleep(Duration::from_secs(10)).await; Ok(Response::new(Body::empty())) } - EndpointBehavior::InternalServerError => - Ok(Response::builder() - .status(StatusCode::INTERNAL_SERVER_ERROR) - .body(Body::from("Internal Server Error")) - .unwrap()), - EndpointBehavior::Unauthorized => - Ok(Response::builder() - .status(StatusCode::UNAUTHORIZED) - .body(Body::from("Unauthorized")) - .unwrap()), + EndpointBehavior::InternalServerError => Ok(Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .body(Body::from("Internal Server Error")) + .unwrap()), + EndpointBehavior::Unauthorized => Ok(Response::builder() + .status(StatusCode::UNAUTHORIZED) + .body(Body::from("Unauthorized")) + .unwrap()), EndpointBehavior::MalformedJson => Ok(Response::builder() .status(StatusCode::OK) .header(CONTENT_TYPE, HeaderValue::from_static("application/json")) From a8d09fa1b710b6ca483d312a7991a8ccef5c4a61 Mon Sep 17 00:00:00 2001 From: CryptCortex Date: Tue, 20 May 2025 10:16:10 +0100 Subject: [PATCH 10/14] test: add retry exhaustion test for HTTP client --- mantis-sdk/tests/auctioneer_http_test.rs | 68 ++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/mantis-sdk/tests/auctioneer_http_test.rs b/mantis-sdk/tests/auctioneer_http_test.rs index 65565e8..d40f75d 100644 --- a/mantis-sdk/tests/auctioneer_http_test.rs +++ b/mantis-sdk/tests/auctioneer_http_test.rs @@ -416,6 +416,74 @@ async fn test_http_client_with_retry_success() { ); } +#[tokio::test] +#[traced_test] +async fn test_http_client_with_retry_exhaustion() { + let mock_server = MockHttpAuctioneer::new().await.unwrap(); + + let server_config = mock_server.config(); + server_config.request_counter.store(0, Ordering::SeqCst); + + let behaviors = vec![ + EndpointBehavior::InternalServerError, + EndpointBehavior::InternalServerError, + EndpointBehavior::InternalServerError, + EndpointBehavior::InternalServerError, + ]; + + server_config + .add_endpoint(EndpointConfig::with_sequence("/api/v1/intents", behaviors)) + .await; + + let max_retries = 2; // We'll try initial request + 2 retries + let client = AuctioneerHttpClient::new( + &mock_server.base_url(), + Some(HttpClientConfig { + request_timeout: Duration::from_secs(5), + max_retries, + authority_env_var: "TEST_MANTIS_ADMIN_KEY".to_string(), + retry_base_delay: Duration::from_millis(50), // Fast retry for tests + api_version: "v1".to_string(), + }), + ) + .unwrap(); + + let query = ListSwapIntentsQuery { + src_chain: None, + period: None, + src_user: vec![], + page: None, + page_size: None, + }; + + let result = client.list_swap_intents(query).await; + + // Sleep a bit to make sure all requests are counted + tokio::time::sleep(Duration::from_millis(100)).await; + + let request_count = server_config.get_request_count(); + println!("Final request count in exhaustion test: {}", request_count); + + assert!(result.is_err(), "Expected error after retries are exhausted"); + + let err = result.unwrap_err(); + let err_str = err.to_string(); + assert!( + err_str.contains("Server responded with 500"), + "Expected server error message, but got: {}", + err_str + ); + + assert_eq!( + request_count, + (max_retries + 1) as u64, + "Expected exactly {} requests (initial + {} retries), but got {}", + max_retries + 1, + max_retries, + request_count + ); +} + #[tokio::test] #[traced_test] async fn test_http_client_with_global_delay() { From 5c64df6390887c99c903b44bcd0694a7cbf38c4e Mon Sep 17 00:00:00 2001 From: CryptCortex Date: Tue, 20 May 2025 10:33:02 +0100 Subject: [PATCH 11/14] test: enhance auth key testing in HTTP client --- mantis-sdk/tests/auctioneer_http_test.rs | 82 +++++++++++++++++++++++- mantis-sdk/tests/mock_http_auctioneer.rs | 56 ++++++++++++++-- 2 files changed, 133 insertions(+), 5 deletions(-) diff --git a/mantis-sdk/tests/auctioneer_http_test.rs b/mantis-sdk/tests/auctioneer_http_test.rs index d40f75d..948a11c 100644 --- a/mantis-sdk/tests/auctioneer_http_test.rs +++ b/mantis-sdk/tests/auctioneer_http_test.rs @@ -67,6 +67,11 @@ async fn test_auth_key_included_when_env_set() { let mock_server = MockHttpAuctioneer::new().await.unwrap(); + let server_config = mock_server.config(); + server_config + .set_expected_authority_key(Some(key_value.to_string())) + .await; + let client = AuctioneerHttpClient::new( &mock_server.base_url(), Some(HttpClientConfig { @@ -79,15 +84,90 @@ async fn test_auth_key_included_when_env_set() { ) .unwrap(); - // Test auth succeeds when env var is set let result = client.list_fees().await; println!("Auth with env var set result: {:?}", result); assert!(result.is_ok(), "Expected auth to succeed when env var is set"); + let received_key = server_config.get_received_authority_key().await; + assert!( + received_key.is_some(), + "Server should have received an authority key" + ); + assert_eq!( + received_key.unwrap(), + key_value, + "Server received incorrect authority key" + ); + env::remove_var("TEST_MANTIS_ADMIN_KEY"); } +#[tokio::test] +#[traced_test] +async fn test_auth_key_rejection_with_wrong_key() { + let env_var_name = "TEST_MANTIS_ADMIN_KEY_WRONG_TEST"; + env::remove_var(env_var_name); + + let correct_key = "correct_auth_key"; + let wrong_key = "wrong_auth_key"; + + // Set incorrect key in environment + env::set_var(env_var_name, wrong_key); + + let auth_key_result = env::var(env_var_name); + assert!(auth_key_result.is_ok()); + assert_eq!(auth_key_result.unwrap(), wrong_key); + + let mock_server = MockHttpAuctioneer::new().await.unwrap(); + + // Set the expected authority key in the mock server (different from env) + let server_config = mock_server.config(); + server_config + .set_expected_authority_key(Some(correct_key.to_string())) + .await; + + let client = AuctioneerHttpClient::new( + &mock_server.base_url(), + Some(HttpClientConfig { + request_timeout: Duration::from_secs(5), + max_retries: 0, + authority_env_var: env_var_name.to_string(), + retry_base_delay: Duration::from_millis(100), + api_version: "v1".to_string(), + }), + ) + .unwrap(); + + let result = client.list_fees().await; + + println!("Auth with wrong key result: {:?}", result); + assert!(result.is_err(), "Expected auth to fail with wrong key"); + + // Check error type and message + let err = result.unwrap_err(); + let err_str = err.to_string(); + assert!( + err_str.contains("Unauthorized"), + "Expected error to contain 'Unauthorized', got: {}", + err_str + ); + + // Check that the server received the wrong authority key + let received_key = server_config.get_received_authority_key().await; + assert!( + received_key.is_some(), + "Server should have received an authority key" + ); + assert_eq!( + received_key.unwrap(), + wrong_key, + "Server received unexpected authority key" + ); + + env::remove_var(env_var_name); +} + #[tokio::test] #[traced_test] async fn test_http_client_with_successful_response() { diff --git a/mantis-sdk/tests/mock_http_auctioneer.rs b/mantis-sdk/tests/mock_http_auctioneer.rs index ec09151..07b5527 100644 --- a/mantis-sdk/tests/mock_http_auctioneer.rs +++ b/mantis-sdk/tests/mock_http_auctioneer.rs @@ -91,6 +91,8 @@ pub struct MockServerConfig { pub global_delay: Arc>>, pub global_delay_applied: Arc, pub unresponsive: Arc, + pub received_authority_key: Arc>>, + pub expected_authority_key: Arc>>, } impl MockServerConfig { @@ -101,9 +103,21 @@ impl MockServerConfig { global_delay: Arc::new(Mutex::new(None)), global_delay_applied: Arc::new(AtomicBool::new(false)), unresponsive: Arc::new(AtomicBool::new(false)), + received_authority_key: Arc::new(Mutex::new(None)), + expected_authority_key: Arc::new(Mutex::new(None)), } } + pub async fn set_expected_authority_key(&self, key: Option) { + let mut expected = self.expected_authority_key.lock().await; + *expected = key; + } + + pub async fn get_received_authority_key(&self) -> Option { + let received = self.received_authority_key.lock().await; + received.clone() + } + pub fn was_global_delay_applied(&self) -> bool { self.global_delay_applied.load(Ordering::SeqCst) } @@ -236,6 +250,36 @@ async fn handle_request(req: Request, config: MockServerConfig) -> Result< return Ok(Response::new(Body::empty())); } + // Extract authority key from query parameters if it exists + if let Some(query) = req.uri().query() { + let query_pairs: Vec<(String, String)> = url::form_urlencoded::parse(query.as_bytes()) + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + + if let Some((_, auth_value)) = query_pairs.iter().find(|(k, _)| k == "authority") { + let mut received = config.received_authority_key.lock().await; + *received = Some(auth_value.clone()); + tracing::info!("Received authority key: {}", auth_value); + + // If there's an expected key set, verify it matches + let expected_opt = config.expected_authority_key.lock().await; + if let Some(expected) = expected_opt.as_ref() { + if auth_value != expected { + tracing::info!( + "Authority key mismatch: expected '{}', got '{}'", + expected, + auth_value + ); + return Ok(Response::builder() + .status(StatusCode::UNAUTHORIZED) + .body(Body::from("Invalid authority key")) + .unwrap()); + } + tracing::info!("Authority key matched expected value"); + } + } + } + // Apply global delay if any - do this before any other processing { // Use a separate scope for the mutex lock @@ -347,10 +391,14 @@ fn generate_mock_response(path: &str) -> Response { total_value_out: vec![990.0], total_fees: vec![10.0], }), - "/api/v1/fees" => json_response(&auctioneer_api::http::ListFeesResponse { - solana: vec![], - ethereum: vec![], - }), + "/api/v1/fees" => { + // This is an admin endpoint, so we should check for authority key + // The actual checking happens in handle_request + json_response(&auctioneer_api::http::ListFeesResponse { + solana: vec![], + ethereum: vec![], + }) + } _ if path.starts_with("/api/v1/intents/") => { let id_str = path.trim_start_matches("/api/v1/intents/"); let id = id_str.parse::().unwrap_or(123); From 92650bdd7616dab9f5040ed44ac3e00652998ab3 Mon Sep 17 00:00:00 2001 From: CryptCortex Date: Fri, 23 May 2025 11:59:26 +0100 Subject: [PATCH 12/14] refactor: replace query-based auth with Bearer token authentication --- mantis-auctioneer-api/src/http.rs | 21 ++------ mantis-sdk/src/auction/http.rs | 86 ++++++++++++++++--------------- 2 files changed, 47 insertions(+), 60 deletions(-) diff --git a/mantis-auctioneer-api/src/http.rs b/mantis-auctioneer-api/src/http.rs index 03aaab9..c9f4063 100644 --- a/mantis-auctioneer-api/src/http.rs +++ b/mantis-auctioneer-api/src/http.rs @@ -69,11 +69,8 @@ pub struct ListQuotesResponse { pub solver_quotes: Vec, } -#[derive(Debug, Serialize, Deserialize, Validate, ToSchema)] -pub struct ListFeesQuery { - #[validate(custom(function = "validate_authority"))] - pub authority: String, -} +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct ListFeesQuery {} #[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct ListFeesResponse { @@ -163,10 +160,8 @@ pub struct SwapIntent { pub token_out_decimals: Option, } -#[derive(Debug, Serialize, Deserialize, Validate, ToSchema)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct RescanQuery { - #[validate(custom(function = "validate_authority"))] - pub authority: String, pub src_chain: IntentChain, pub start: u64, pub end: u64, @@ -177,8 +172,6 @@ pub struct RescanResponse {} #[derive(Debug, Serialize, Deserialize, Validate, ToSchema)] pub struct UnlockQuery { - #[validate(custom(function = "validate_authority"))] - pub authority: String, #[validate(custom(function = "validate_intent_id"))] pub intent_id: u64, pub src_chain: IntentChain, @@ -194,8 +187,6 @@ pub struct UnlockResponse { #[derive(Debug, Serialize, Deserialize, Validate, ToSchema)] pub struct CancelQuery { - #[validate(custom(function = "validate_authority"))] - pub authority: String, #[validate(custom(function = "validate_intent_id"))] pub intent_id: u64, pub src_chain: IntentChain, @@ -208,12 +199,6 @@ pub struct CancelResponse { pub transaction: String, } -fn validate_authority(authority: &str) -> Result<(), ValidationError> { - if authority != "4e6d9d0849740b385d60c59fded9ee97" { - return Err(ValidationError::new("Invalid authority")); - } - Ok(()) -} pub fn validate_intent_id(intent_id: u64) -> Result<(), ValidationError> { if !(100_000_000_000..=999_999_999_999).contains(&intent_id) { diff --git a/mantis-sdk/src/auction/http.rs b/mantis-sdk/src/auction/http.rs index 9d4c286..99a8186 100644 --- a/mantis-sdk/src/auction/http.rs +++ b/mantis-sdk/src/auction/http.rs @@ -6,7 +6,7 @@ use auctioneer_api::http::{ }; use auctioneer_api::IntentChain; use reqwest::{Client, StatusCode}; -use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use serde::{de::DeserializeOwned, Serialize}; use std::env; use std::time::Duration; use tracing::error; @@ -52,7 +52,8 @@ pub enum AuctioneerHttpError { pub struct HttpClientConfig { pub request_timeout: Duration, pub max_retries: u32, - pub authority_env_var: String, + pub admin_token: Option, + pub admin_token_env_var: String, pub retry_base_delay: Duration, pub api_version: String, } @@ -62,9 +63,10 @@ impl Default for HttpClientConfig { Self { request_timeout: Duration::from_secs(30), max_retries: 3, - authority_env_var: "MANTIS_ADMIN_AUTHORITY_KEY".to_string(), + admin_token: None, + admin_token_env_var: "ADMIN_TOKEN".to_string(), retry_base_delay: Duration::from_secs(1), - api_version: "v1".to_string(), + api_version: auctioneer_api::API_VERSION.to_string(), } } } @@ -77,9 +79,9 @@ pub struct AuctioneerHttpClient { #[derive(Debug, thiserror::Error)] pub enum AuthError { - #[error("Authority key not found in environment")] + #[error("Admin token not found in environment")] MissingAuthorityKey, - #[error("Unauthorized: Invalid authority key")] + #[error("Unauthorized: Invalid admin token")] Unauthorized, #[error("Server error: {0}")] ServerError(String), @@ -104,21 +106,14 @@ impl AuctioneerHttpClient { }) } - fn get_authority_key(&self) -> std::result::Result { - env::var(&self.config.authority_env_var).map_err(|_| AuthError::MissingAuthorityKey) - } + fn get_auth_token(&self) -> std::result::Result { + // First check if token is provided in config + if let Some(token) = &self.config.admin_token { + return Ok(token.clone()); + } - pub(crate) fn add_authority_to_query Deserialize<'de>>( - &self, - query: T, - ) -> Result { - let serialized = serde_json::to_value(&query)?; - let mut deserialized = serialized.as_object().cloned().unwrap_or_default(); - deserialized.insert( - "authority".to_string(), - serde_json::Value::String(self.get_authority_key().map_err(AuctioneerHttpError::AuthError)?), - ); - Ok(serde_json::from_value(serde_json::Value::Object(deserialized))?) + // Fallback to environment variable + env::var(&self.config.admin_token_env_var).map_err(|_| AuthError::MissingAuthorityKey) } async fn request( @@ -126,6 +121,7 @@ impl AuctioneerHttpClient { method: reqwest::Method, path: &str, query: Option, + require_auth: bool, ) -> Result { let api_path = format!("/api/{}{}", self.config.api_version, path); let url = self.base_url.join(&api_path)?; @@ -139,6 +135,11 @@ impl AuctioneerHttpClient { request = request.query(q); } + if require_auth { + let token = self.get_auth_token().map_err(AuctioneerHttpError::AuthError)?; + request = request.header("Authorization", format!("Bearer {}", token)); + } + match request.send().await { Ok(response) => match response.status() { StatusCode::OK => { @@ -220,19 +221,20 @@ impl AuctioneerHttpClient { // Public endpoints pub async fn health_check(&self) -> Result { let response = self - .request::(reqwest::Method::GET, "/health", None) + .request::(reqwest::Method::GET, "/health", None, false) .await?; Ok(response.status == "ok") } pub async fn list_quotes(&self, query: ListQuotesQuery) -> Result { - self.request(reqwest::Method::GET, "/quotes", Some(query)).await + self.request(reqwest::Method::GET, "/quotes", Some(query), false) + .await } pub async fn list_swap_intents(&self, _query: ListSwapIntentsQuery) -> Result { // We're not using the query in tests to avoid serialization issues - self.request::(reqwest::Method::GET, "/intents", None) + self.request::(reqwest::Method::GET, "/intents", None, false) .await } @@ -241,26 +243,26 @@ impl AuctioneerHttpClient { reqwest::Method::GET, &format!("/intents/{}", intent_id), None, + false, ) .await } pub async fn get_stats(&self, query: GetStatsQuery) -> Result { - self.request(reqwest::Method::GET, "/stats", Some(query)).await + self.request(reqwest::Method::GET, "/stats", Some(query), false) + .await } pub async fn get_time_series(&self, query: GetTimeSeriesQuery) -> Result { - self.request(reqwest::Method::GET, "/time_series", Some(query)) + self.request(reqwest::Method::GET, "/time_series", Some(query), false) .await } // Admin endpoints pub async fn list_fees(&self) -> Result { - let query = self.add_authority_to_query(ListFeesQuery { - authority: String::new(), // Will be replaced by add_authority_to_query - })?; - - self.request(reqwest::Method::GET, "/fees", Some(query)).await + let query = ListFeesQuery {}; + self.request(reqwest::Method::GET, "/fees", Some(query), true) + .await } pub async fn cancel_intent( @@ -270,15 +272,15 @@ impl AuctioneerHttpClient { token_in_mint: Option, src_user: Option, ) -> Result { - let query = self.add_authority_to_query(CancelQuery { - authority: String::new(), // Will be replaced by add_authority_to_query + let query = CancelQuery { intent_id, src_chain, token_in_mint, src_user, - })?; + }; - self.request(reqwest::Method::GET, "/cancel", Some(query)).await + self.request(reqwest::Method::GET, "/cancel", Some(query), true) + .await } pub async fn unlock_solver_funds( @@ -289,26 +291,26 @@ impl AuctioneerHttpClient { amount_out: String, dst_user: String, ) -> Result { - let query = self.add_authority_to_query(UnlockQuery { - authority: String::new(), // Will be replaced by add_authority_to_query + let query = UnlockQuery { intent_id, src_chain, token_out, amount_out, dst_user, - })?; + }; - self.request(reqwest::Method::GET, "/unlock", Some(query)).await + self.request(reqwest::Method::GET, "/unlock", Some(query), true) + .await } pub async fn rescan_chain(&self, src_chain: IntentChain, start: u64, end: u64) -> Result { - let query = self.add_authority_to_query(RescanQuery { - authority: String::new(), // Will be replaced by add_authority_to_query + let query = RescanQuery { src_chain, start, end, - })?; + }; - self.request(reqwest::Method::GET, "/rescan", Some(query)).await + self.request(reqwest::Method::GET, "/rescan", Some(query), true) + .await } } From 3376cf383da533d4ad58dddb94b0b5799669fce0 Mon Sep 17 00:00:00 2001 From: CryptCortex Date: Fri, 23 May 2025 12:27:23 +0100 Subject: [PATCH 13/14] refactor: convenience api --- mantis-sdk/src/auction/http.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/mantis-sdk/src/auction/http.rs b/mantis-sdk/src/auction/http.rs index 99a8186..5d7c065 100644 --- a/mantis-sdk/src/auction/http.rs +++ b/mantis-sdk/src/auction/http.rs @@ -106,6 +106,14 @@ impl AuctioneerHttpClient { }) } + pub fn build(base_url: &str) -> Result { + Self::new(base_url, None) + } + + pub fn build_with_config(base_url: &str, config: HttpClientConfig) -> Result { + Self::new(base_url, Some(config)) + } + fn get_auth_token(&self) -> std::result::Result { // First check if token is provided in config if let Some(token) = &self.config.admin_token { From 5001dfe1cfc77d1580fc7954b7e99a50e55bad0b Mon Sep 17 00:00:00 2001 From: CryptCortex Date: Tue, 27 May 2025 12:19:49 +0100 Subject: [PATCH 14/14] feat: example solvers --- Cargo.lock | 2 + mantis-sdk/Cargo.toml | 10 + mantis-sdk/examples/.gitignore | 5 + mantis-sdk/examples/README.md | 57 +++++ mantis-sdk/examples/env.example | 17 ++ mantis-sdk/examples/minimal_solver.rs | 92 +++++++ mantis-sdk/examples/simple_solver.rs | 331 ++++++++++++++++++++++++++ 7 files changed, 514 insertions(+) create mode 100644 mantis-sdk/examples/.gitignore create mode 100644 mantis-sdk/examples/README.md create mode 100644 mantis-sdk/examples/env.example create mode 100644 mantis-sdk/examples/minimal_solver.rs create mode 100644 mantis-sdk/examples/simple_solver.rs diff --git a/Cargo.lock b/Cargo.lock index 5bb5ce8..2204406 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4904,7 +4904,9 @@ dependencies = [ "anchor-spl", "anyhow", "base64 0.22.1", + "bs58", "chrono", + "dotenv", "futures 0.3.31", "hyper 0.14.32", "mantis-auctioneer-api", diff --git a/mantis-sdk/Cargo.toml b/mantis-sdk/Cargo.toml index 377543c..4e5b820 100644 --- a/mantis-sdk/Cargo.toml +++ b/mantis-sdk/Cargo.toml @@ -44,6 +44,16 @@ tracing-subscriber = { workspace = true } url = { workspace = true } uuid = { workspace = true } auctioneer-api = { workspace = true } +bs58 = "0.5" +dotenv = "0.15" + +[[example]] +name = "simple_solver" +path = "examples/simple_solver.rs" + +[[example]] +name = "minimal_solver" +path = "examples/minimal_solver.rs" [dev-dependencies] tracing-test = "0.2.5" diff --git a/mantis-sdk/examples/.gitignore b/mantis-sdk/examples/.gitignore new file mode 100644 index 0000000..2141e59 --- /dev/null +++ b/mantis-sdk/examples/.gitignore @@ -0,0 +1,5 @@ +# Environment files +.env +.env.local +.env.*.local + diff --git a/mantis-sdk/examples/README.md b/mantis-sdk/examples/README.md new file mode 100644 index 0000000..c2b6163 --- /dev/null +++ b/mantis-sdk/examples/README.md @@ -0,0 +1,57 @@ +# Solver Examples + +This directory contains example solvers demonstrating how to use the Mantis SDK: + +1. **minimal_solver.rs** - The simplest possible solver implementation +2. **simple_solver.rs** - A more complete example with proper structure and error handling + +## Overview + +This example shows: +- Connecting to the auctioneer via WebSocket +- Registering as a solver +- Receiving and bidding on auctions +- Executing swaps when winning auctions +- Handling quotes + +## Quick Start + +1. Copy the example environment file and fill in your values: + +```bash +cd examples +cp .env.example .env +# Edit .env with your private keys and configuration +``` + +2. Run the solvers: + +```bash +# Minimal example +cargo run --example minimal_solver + +# Simple example with more features +cargo run --example simple_solver +``` + +## Environment Variables + +The examples use the following environment variables (loaded from `.env` file): + +| Variable | Description | Required | Default | +|----------|-------------|----------|---------| +| `SOLVER_ID` | Unique identifier for your solver (5-15 alphanumeric chars) | No | `simple001` or `minimal001` | +| `AUCTIONEER_WS_URL` | WebSocket URL of the auctioneer service | No | `ws://localhost:8080/auction` | +| `ETHEREUM_PRIVATE_KEY` | Ethereum private key (hex without 0x prefix) | Yes | - | +| `SOLANA_PRIVATE_KEY` | Solana private key (base58 encoded) | Yes | - | +| `COMMISSION_BPS` | Commission in basis points (100 = 1%) | No | `100` | + +## Extending the Example(s) + +To build a production solver: +1. Implement real swap execution via DEX integrations +2. Add sophisticated pricing and profitability models +3. Implement proper error recovery and retry logic +4. Add monitoring and metrics +5. Support more chains (Base, etc.) +6. Implement actual cross-chain bridging diff --git a/mantis-sdk/examples/env.example b/mantis-sdk/examples/env.example new file mode 100644 index 0000000..2888c8a --- /dev/null +++ b/mantis-sdk/examples/env.example @@ -0,0 +1,17 @@ +# Solver Configuration +# Copy this file to .env and fill in your values + +# Solver identifier (alphanumeric, 5-15 characters) +SOLVER_ID=mysolver001 + +# Auctioneer WebSocket URL +AUCTIONEER_WS_URL=ws://localhost:8080/auction + +# Ethereum private key (without 0x prefix) +ETHEREUM_PRIVATE_KEY=your_ethereum_private_key_here + +# Solana private key (base58 encoded) +SOLANA_PRIVATE_KEY=your_solana_private_key_base58_here + +# Commission in basis points (100 = 1%) +COMMISSION_BPS=100 \ No newline at end of file diff --git a/mantis-sdk/examples/minimal_solver.rs b/mantis-sdk/examples/minimal_solver.rs new file mode 100644 index 0000000..f971d4a --- /dev/null +++ b/mantis-sdk/examples/minimal_solver.rs @@ -0,0 +1,92 @@ +use std::env; + +use alloy::primitives::U256; +use alloy::signers::local::PrivateKeySigner; +use anyhow::{Context, Result}; +use mantis_sdk::auction::ws::AuctioneerWsClient; +use solana_sdk::signature::{Keypair, Signer}; +use tracing::info; + +use auctioneer_api::ws::{ + ClientBidMessage, ClientMessage, ClientRegisterMessage, ServerMessage, SignableMessage, + SolverAddresses, +}; + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt::init(); + + // Load .env file if it exists + dotenv::dotenv().ok(); + + // Load configuration + let solver_id = env::var("SOLVER_ID").unwrap_or_else(|_| "minimal001".to_string()); + let ws_url = env::var("AUCTIONEER_WS_URL").unwrap_or_else(|_| "ws://localhost:8080/auction".to_string()); + + // Setup signers + let ethereum_signer = env::var("ETHEREUM_PRIVATE_KEY") + .context("ETHEREUM_PRIVATE_KEY required")? + .parse::()?; + + let solana_keypair = Keypair::from_bytes( + &bs58::decode(env::var("SOLANA_PRIVATE_KEY").context("SOLANA_PRIVATE_KEY required")?) + .into_vec()? + )?; + + // Connect to auctioneer + let ws_client = AuctioneerWsClient::connect(&ws_url, None).await?; + info!("Connected to auctioneer at {}", ws_url); + + // Register solver + let register_msg = ClientRegisterMessage::new( + solver_id.clone(), + SolverAddresses { + ethereum: ethereum_signer.address(), + solana: solana_keypair.pubkey(), + base: ethereum_signer.address(), + }, + ) + .signed(ethereum_signer.clone())?; + + ws_client.send_message(ClientMessage::Register(register_msg)).await?; + info!("Registered solver: {}", solver_id); + + // Main message loop + loop { + match ws_client.receive_message().await { + Ok(ServerMessage::AuctionStart(auction)) => { + info!("Auction {} started", auction.intent_id); + + // Simple bid: just bid the input amount minus 1% + let amount_in = U256::from_str_radix(&auction.intent.amount_in, 10)?; + let bid_amount = amount_in * U256::from(99) / U256::from(100); + + let bid = ClientBidMessage::new( + solver_id.clone(), + auction.intent_id, + bid_amount.to_string(), + ) + .signed(ethereum_signer.clone())?; + + ws_client.send_message(ClientMessage::Bid(bid)).await?; + info!("Placed bid for auction {}", auction.intent_id); + } + Ok(ServerMessage::AuctionResult(result)) => { + if result.won { + info!("Won auction {}! Amount: {}", result.intent_id, result.amount); + // In a real solver, execute the swap here + } + } + Ok(ServerMessage::Error(e)) => { + eprintln!("Server error: {} - {}", e.code, e.message); + } + Ok(_) => {} // Handle other message types as needed + Err(e) => { + eprintln!("Error receiving message: {}", e); + break; + } + } + } + + Ok(()) +} \ No newline at end of file diff --git a/mantis-sdk/examples/simple_solver.rs b/mantis-sdk/examples/simple_solver.rs new file mode 100644 index 0000000..e25d020 --- /dev/null +++ b/mantis-sdk/examples/simple_solver.rs @@ -0,0 +1,331 @@ +use std::collections::HashMap; +use std::env; +use std::sync::Arc; + +use alloy::primitives::U256; +use alloy::signers::local::PrivateKeySigner; +use anyhow::{Context, Result}; +use mantis_sdk::auction::ws::AuctioneerWsClient; +use solana_sdk::signature::{Keypair, Signer}; +use tokio::sync::Mutex; +use tracing::{error, info, warn}; + +use auctioneer_api::ws::{ + ClientBidMessage, ClientMessage, ClientQuoteMessage, ClientRegisterMessage, ClientSolveMessage, + IntentChain, ServerAuctionResultMessage, ServerAuctionStartMessage, ServerMessage, ServerQuoteMessage, + SignableMessage, SolverAddresses, SwapIntent, +}; + +#[derive(Clone)] +struct SolverConfig { + solver_id: String, + auctioneer_ws_url: String, + ethereum_private_key: String, + solana_private_key: String, + commission_bps: u16, +} + +impl SolverConfig { + fn from_env() -> Result { + Ok(SolverConfig { + solver_id: env::var("SOLVER_ID").unwrap_or_else(|_| "simple001".to_string()), + auctioneer_ws_url: env::var("AUCTIONEER_WS_URL") + .unwrap_or_else(|_| "ws://localhost:8080/auction".to_string()), + ethereum_private_key: env::var("ETHEREUM_PRIVATE_KEY") + .context("ETHEREUM_PRIVATE_KEY must be set")?, + solana_private_key: env::var("SOLANA_PRIVATE_KEY").context("SOLANA_PRIVATE_KEY must be set")?, + commission_bps: env::var("COMMISSION_BPS") + .unwrap_or_else(|_| "100".to_string()) + .parse() + .context("Invalid COMMISSION_BPS")?, + }) + } +} + +#[derive(Clone)] +struct SolverContext { + config: SolverConfig, + ethereum_signer: PrivateKeySigner, + solana_keypair: Arc, + pending_intents: Arc>>, +} + +impl SolverContext { + async fn new(config: SolverConfig) -> Result { + let ethereum_signer = config + .ethereum_private_key + .parse::() + .context("Invalid Ethereum private key")?; + + let solana_keypair_bytes = bs58::decode(&config.solana_private_key) + .into_vec() + .context("Invalid Solana private key")?; + let solana_keypair = Arc::new(Keypair::from_bytes(&solana_keypair_bytes)?); + + Ok(SolverContext { + config, + ethereum_signer, + solana_keypair, + pending_intents: Arc::new(Mutex::new(HashMap::new())), + }) + } + + fn get_solver_addresses(&self) -> SolverAddresses { + SolverAddresses { + ethereum: self.ethereum_signer.address(), + solana: self.solana_keypair.as_ref().pubkey(), + base: self.ethereum_signer.address(), + } + } + + async fn calculate_output_amount(&self, intent: &SwapIntent) -> Result { + let amount_in = U256::from_str_radix(&intent.amount_in, 10)?; + + let commission = amount_in * U256::from(self.config.commission_bps) / U256::from(10000); + let output_amount = amount_in - commission; + + info!( + intent_id = intent.timeout, + input = %amount_in, + commission = %commission, + output = %output_amount, + "Calculated output amount" + ); + + Ok(output_amount) + } + + async fn execute_swap(&self, intent: &SwapIntent) -> Result { + match (intent.src_chain.clone(), intent.dst_chain.clone()) { + (IntentChain::Ethereum, IntentChain::Ethereum) => { + info!("Executing Ethereum to Ethereum swap"); + Ok("0x1234567890abcdef".to_string()) + } + (IntentChain::Solana, IntentChain::Solana) => { + info!("Executing Solana to Solana swap"); + Ok("5XE5bHJhJ8VZ8Z".to_string()) + } + (IntentChain::Ethereum, IntentChain::Solana) => { + info!("Executing Ethereum to Solana cross-chain swap"); + Ok("cross_chain_tx_123".to_string()) + } + (IntentChain::Solana, IntentChain::Ethereum) => { + info!("Executing Solana to Ethereum cross-chain swap"); + Ok("cross_chain_tx_456".to_string()) + } + _ => { + warn!("Unsupported chain combination"); + Err(anyhow::anyhow!("Unsupported chain combination")) + } + } + } +} + +async fn handle_auction_start( + ctx: Arc, + ws_client: Arc, + msg: ServerAuctionStartMessage, +) -> Result<()> { + info!( + intent_id = msg.intent_id, + src_chain = ?msg.intent.src_chain, + dst_chain = ?msg.intent.dst_chain, + "Received auction start" + ); + + let output_amount = ctx.calculate_output_amount(&msg.intent).await?; + + let min_amount_out = U256::from_str_radix(&msg.intent.amount_out, 10)?; + if output_amount < min_amount_out { + info!( + intent_id = msg.intent_id, + calculated = %output_amount, + required = %min_amount_out, + "Skipping unprofitable auction" + ); + return Ok(()); + } + + ctx.pending_intents + .lock() + .await + .insert(msg.intent_id, msg.intent.clone()); + + let bid_msg = ClientBidMessage::new( + ctx.config.solver_id.clone(), + msg.intent_id, + output_amount.to_string(), + ) + .signed(ctx.ethereum_signer.clone())?; + + ws_client + .send_message(ClientMessage::Bid(bid_msg)) + .await + .context("Failed to send bid")?; + + info!( + intent_id = msg.intent_id, + amount = %output_amount, + "Sent bid" + ); + + Ok(()) +} + +async fn handle_auction_result( + ctx: Arc, + ws_client: Arc, + msg: ServerAuctionResultMessage, +) -> Result<()> { + if !msg.won { + info!(intent_id = msg.intent_id, "Lost auction"); + ctx.pending_intents.lock().await.remove(&msg.intent_id); + return Ok(()); + } + + info!(intent_id = msg.intent_id, amount = msg.amount, "Won auction!"); + + let intent = ctx + .pending_intents + .lock() + .await + .remove(&msg.intent_id) + .context("Intent not found in pending intents")?; + + match ctx.execute_swap(&intent).await { + Ok(tx_hash) => { + let solve_msg = + ClientSolveMessage::new(ctx.config.solver_id.clone(), msg.intent_id, tx_hash.clone()) + .signed(ctx.ethereum_signer.clone())?; + + ws_client + .send_message(ClientMessage::Solve(solve_msg)) + .await + .context("Failed to send solve message")?; + + info!(intent_id = msg.intent_id, tx_hash = tx_hash, "Sent solve message"); + } + Err(e) => { + error!( + intent_id = msg.intent_id, + error = %e, + "Failed to execute swap" + ); + } + } + + Ok(()) +} + +async fn handle_quote_request( + ctx: Arc, + ws_client: Arc, + msg: ServerQuoteMessage, +) -> Result<()> { + info!( + request_id = %msg.request_id, + "Received quote request" + ); + + let output_amount = ctx.calculate_output_amount(&msg.intent).await?; + + let quote_response = ClientQuoteMessage { + request_id: msg.request_id, + src_chain: msg.intent.src_chain.to_string(), + dst_chain: msg.intent.dst_chain.to_string(), + solver_id: ctx.config.solver_id.clone(), + token_in: msg.intent.token_in.clone(), + amount_in: msg.intent.amount_in.clone(), + token_out: msg.intent.token_out.clone(), + amount_out: output_amount.to_string(), + }; + + ws_client + .send_message(ClientMessage::Quote(quote_response)) + .await + .context("Failed to send quote response")?; + + Ok(()) +} + +async fn handle_server_message( + ctx: Arc, + ws_client: Arc, + message: ServerMessage, +) -> Result<()> { + match message { + ServerMessage::AuctionStart(msg) => { + tokio::spawn(handle_auction_start(ctx, ws_client, msg)); + } + ServerMessage::AuctionResult(msg) => { + tokio::spawn(handle_auction_result(ctx, ws_client, msg)); + } + ServerMessage::Quote(msg) => { + tokio::spawn(handle_quote_request(ctx, ws_client, msg)); + } + ServerMessage::UnlockedFunds(msg) => { + info!( + intent_id = msg.intent_id, + amount = msg.amount_in, + "Funds unlocked notification" + ); + } + ServerMessage::Error(msg) => { + error!( + code = msg.code, + message = msg.message, + request_id = ?msg.request_id, + "Server error" + ); + } + } + Ok(()) +} + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt::init(); + + // Load .env file if it exists + dotenv::dotenv().ok(); + + info!("Starting simple solver example"); + + let config = SolverConfig::from_env()?; + let ctx = Arc::new(SolverContext::new(config.clone()).await?); + + let ws_client = Arc::new( + AuctioneerWsClient::connect(&config.auctioneer_ws_url, None) + .await + .context("Failed to create WebSocket client")?, + ); + + let register_msg = ClientRegisterMessage::new(config.solver_id.clone(), ctx.get_solver_addresses()) + .signed(ctx.ethereum_signer.clone())?; + + ws_client + .send_message(ClientMessage::Register(register_msg)) + .await + .context("Failed to send registration")?; + + info!(solver_id = config.solver_id, "Sent registration"); + + loop { + tokio::select! { + Ok(message) = ws_client.receive_message() => { + if let Err(e) = handle_server_message(ctx.clone(), ws_client.clone(), message).await { + error!(error = %e, "Failed to handle server message"); + } + } + _ = tokio::signal::ctrl_c() => { + info!("Shutting down solver"); + break; + } + } + } + + info!("Solver shut down"); + + Ok(()) +} +