diff --git a/packages/core/Cargo.lock b/packages/core/Cargo.lock index 6fbe3de..c133cd8 100644 --- a/packages/core/Cargo.lock +++ b/packages/core/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.4" @@ -11,6 +17,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + [[package]] name = "ascii-canvas" version = "3.0.0" @@ -251,7 +263,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", - "tower", + "tower 0.5.3", "tower-layer", "tower-service", "tracing", @@ -322,6 +334,15 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "blocking" version = "1.6.2" @@ -341,6 +362,12 @@ version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.11.0" @@ -394,6 +421,24 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -406,6 +451,57 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "dashmap" +version = "5.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" +dependencies = [ + "cfg-if", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "dirs-next" version = "2.0.0" @@ -529,12 +625,28 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foreign-types" version = "0.3.2" @@ -559,6 +671,31 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "forwarded-header-value" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8835f84f38484cc86f110a805655697908257fb9a7af005234060891557198e9" +dependencies = [ + "nonempty", + "thiserror 1.0.69", +] + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.31" @@ -566,6 +703,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -574,6 +712,17 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + [[package]] name = "futures-io" version = "0.3.31" @@ -616,20 +765,40 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" + [[package]] name = "futures-util" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ + "futures-channel", "futures-core", + "futures-io", "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "pin-utils", "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -657,6 +826,19 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + [[package]] name = "gloo-timers" version = "0.3.0" @@ -669,6 +851,26 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "governor" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68a7f542ee6b35af73b06abc0dad1c1bae89964e4e253bc4b587b91c9637867b" +dependencies = [ + "cfg-if", + "dashmap", + "futures", + "futures-timer", + "no-std-compat", + "nonzero_ext", + "parking_lot", + "portable-atomic", + "quanta", + "rand 0.8.5", + "smallvec", + "spinning_top", +] + [[package]] name = "h2" version = "0.4.13" @@ -688,12 +890,33 @@ dependencies = [ "tracing", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hermit-abi" version = "0.5.2" @@ -981,6 +1204,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "idna" version = "1.1.0" @@ -1009,7 +1238,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.16.1", + "serde", + "serde_core", ] [[package]] @@ -1099,6 +1330,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "levenshtein" version = "1.0.5" @@ -1157,6 +1394,15 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "matchit" version = "0.7.3" @@ -1175,6 +1421,26 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.1.1" @@ -1209,6 +1475,24 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "no-std-compat" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c" + +[[package]] +name = "nonempty" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9e591e719385e6ebaeb5ce5d3887f7d5676fceca6411d1925ccc95745f3d6f7" + +[[package]] +name = "nonzero_ext" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1218,6 +1502,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -1328,6 +1622,26 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -1371,6 +1685,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + [[package]] name = "potential_utf" version = "0.1.4" @@ -1395,6 +1715,40 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.114", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + [[package]] name = "proc-macro2" version = "1.0.105" @@ -1404,6 +1758,21 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quanta" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7" +dependencies = [ + "crossbeam-utils", + "libc", + "once_cell", + "raw-cpuid", + "wasi", + "web-sys", + "winapi", +] + [[package]] name = "quinn" version = "0.11.9" @@ -1433,7 +1802,7 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand", + "rand 0.9.2", "ring", "rustc-hash", "rustls", @@ -1474,35 +1843,74 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ - "rand_chacha", - "rand_core", + "rand_chacha 0.9.0", + "rand_core 0.9.5", ] [[package]] name = "rand_chacha" -version = "0.9.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", ] [[package]] -name = "rand_core" -version = "0.9.5" +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1586,7 +1994,7 @@ dependencies = [ "tokio", "tokio-native-tls", "tokio-rustls", - "tower", + "tower 0.5.3", "tower-http", "tower-service", "url", @@ -1610,6 +2018,40 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rust-embed" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "syn 2.0.114", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" +dependencies = [ + "sha2", + "walkdir", +] + [[package]] name = "rustc-hash" version = "2.1.1" @@ -1723,6 +2165,12 @@ dependencies = [ "libc", ] +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + [[package]] name = "serde" version = "1.0.228" @@ -1787,6 +2235,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1799,6 +2256,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -1824,6 +2292,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + [[package]] name = "similar" version = "2.7.0" @@ -1868,6 +2342,15 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "spinning_top" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300" +dependencies = [ + "lock_api", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -1880,15 +2363,23 @@ version = "0.0.1" dependencies = [ "axum", "dotenvy", + "governor", "httpmock", "reqwest", "serde", "serde_json", "thiserror 1.0.69", "tokio", + "toml", + "tower 0.4.13", "tower-http", + "tower_governor", "tracing", "tracing-subscriber", + "utoipa", + "utoipa-swagger-ui", + "uuid", + "wiremock", ] [[package]] @@ -2140,6 +2631,58 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "tower" version = "0.5.3" @@ -2169,7 +2712,7 @@ dependencies = [ "http-body 1.0.1", "iri-string", "pin-project-lite", - "tower", + "tower 0.5.3", "tower-layer", "tower-service", ] @@ -2186,6 +2729,22 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" +[[package]] +name = "tower_governor" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aea939ea6cfa7c4880f3e7422616624f97a567c16df67b53b11f0d03917a8e46" +dependencies = [ + "axum", + "forwarded-header-value", + "governor", + "http 1.4.0", + "pin-project", + "thiserror 1.0.69", + "tower 0.5.3", + "tracing", +] + [[package]] name = "tracing" version = "0.1.44" @@ -2230,18 +2789,35 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + [[package]] name = "tracing-subscriber" version = "0.3.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" dependencies = [ + "matchers", "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", "sharded-slab", "smallvec", "thread_local", + "tracing", "tracing-core", "tracing-log", + "tracing-serde", ] [[package]] @@ -2250,6 +2826,18 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.22" @@ -2286,6 +2874,58 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utoipa" +version = "4.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5afb1a60e207dca502682537fefcfd9921e71d0b83e9576060f09abc6efab23" +dependencies = [ + "indexmap", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "4.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c24e8ab68ff9ee746aad22d39b5535601e6416d1b0feeabf78be986a5c4392" +dependencies = [ + "proc-macro-error", + "proc-macro2", + "quote", + "regex", + "syn 2.0.114", +] + +[[package]] +name = "utoipa-swagger-ui" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b39868d43c011961e04b41623e050aedf2cc93652562ff7935ce0f819aaf2da" +dependencies = [ + "axum", + "mime_guess", + "regex", + "rust-embed", + "serde", + "serde_json", + "utoipa", + "zip", +] + +[[package]] +name = "uuid" +version = "1.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" +dependencies = [ + "getrandom 0.4.1", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -2304,6 +2944,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "walkdir" version = "2.5.0" @@ -2338,6 +2984,15 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.108" @@ -2397,6 +3052,40 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "web-sys" version = "0.3.85" @@ -2648,11 +3337,125 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + +[[package]] +name = "wiremock" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" +dependencies = [ + "assert-json-diff", + "base64 0.22.1", + "deadpool", + "futures", + "http 1.4.0", + "http-body-util", + "hyper 1.8.1", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", +] + [[package]] name = "wit-bindgen" version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.114", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.114", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] [[package]] name = "writeable" @@ -2763,6 +3566,18 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "zip" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" +dependencies = [ + "byteorder", + "crc32fast", + "crossbeam-utils", + "flate2", +] + [[package]] name = "zmij" version = "1.0.16" diff --git a/packages/core/Cargo.toml b/packages/core/Cargo.toml index 4694f83..43ad1f9 100644 --- a/packages/core/Cargo.toml +++ b/packages/core/Cargo.toml @@ -12,14 +12,16 @@ dotenvy = "0.15" reqwest = { version = "0.12", features = ["json", "rustls-tls"] } serde = { version = "1.0", features = ["derive"] } thiserror = "1.0" +tower = "0.4" tower-http = { version = "0.6", features = ["cors"] } -tower-governor = "0.4" +tower_governor = "0.4" +governor = "0.6" utoipa = { version = "4", features = ["axum_extras"] } -utoipa-swagger-ui = "6" +utoipa-swagger-ui = { version = "6", features = ["axum"] } uuid = { version = "1", features = ["v4"] } toml = "0.8" [dev-dependencies] httpmock = "0.7" serde_json = "1" -wiremock = "0.6" +wiremock = "0.6" \ No newline at end of file diff --git a/packages/core/src/explain/operation/mod.rs b/packages/core/src/explain/operation/mod.rs index 81ee5e5..0ff6c6f 100644 --- a/packages/core/src/explain/operation/mod.rs +++ b/packages/core/src/explain/operation/mod.rs @@ -7,3 +7,13 @@ pub mod manage_offer; pub mod path_payment; pub mod change_trust; pub mod create_account; +pub mod set_options; + + + + + + + + + diff --git a/packages/core/src/explain/operation/set_options.rs b/packages/core/src/explain/operation/set_options.rs new file mode 100644 index 0000000..3a0f57c --- /dev/null +++ b/packages/core/src/explain/operation/set_options.rs @@ -0,0 +1,380 @@ +//! Explainer for set_options operations. +//! +//! set_options can change many account settings in one operation. +//! This module enumerates every field that was set and assembles +//! them into a single readable summary. + +use crate::models::operation::SetOptionsOperation; +use serde::{Deserialize, Serialize}; + +/// Human-readable explanation of a set_options operation. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct SetOptionsExplanation { + /// Full natural-language summary of what changed. + /// e.g. "GAAAA updated their account: set home domain to example.com, + /// and added signer GBBB...YYYY with weight 1" + pub summary: String, + + /// The account that submitted the operation. "Unknown" if not present. + pub account: String, + + /// One entry per modified field. + /// e.g. ["set home domain to example.com", "added signer GBBB...YYYY with weight 1"] + pub changes: Vec, +} + +/// Produce a human-readable explanation for a set_options operation. +pub fn explain_set_options(op: &SetOptionsOperation) -> SetOptionsExplanation { + let account = op + .source_account + .clone() + .unwrap_or_else(|| "Unknown".to_string()); + + let mut changes: Vec = Vec::new(); + + // Inflation destination + if let Some(ref dest) = op.inflation_dest { + changes.push(format!("set inflation destination to {}", dest)); + } + + // Master key weight + if let Some(weight) = op.master_weight { + if weight == 0 { + changes.push("disabled the master key".to_string()); + } else { + changes.push(format!("set master key weight to {}", weight)); + } + } + + // Thresholds + if let Some(low) = op.low_threshold { + changes.push(format!("set low threshold to {}", low)); + } + if let Some(med) = op.med_threshold { + changes.push(format!("set medium threshold to {}", med)); + } + if let Some(high) = op.high_threshold { + changes.push(format!("set high threshold to {}", high)); + } + + // Home domain + if let Some(ref domain) = op.home_domain { + if domain.is_empty() { + changes.push("cleared the home domain".to_string()); + } else { + changes.push(format!("set home domain to {}", domain)); + } + } + + // Flags + if let Some(flags) = op.set_flags { + if flags > 0 { + changes.push(format!( + "enabled account flag(s): {}", + describe_flags(flags) + )); + } + } + if let Some(flags) = op.clear_flags { + if flags > 0 { + changes.push(format!( + "disabled account flag(s): {}", + describe_flags(flags) + )); + } + } + + // Signer — weight 0 means remove, anything else means add/modify + if let Some(ref key) = op.signer_key { + let short_key = shorten_key(key); + match op.signer_weight { + Some(0) => { + changes.push(format!("removed signer {}", short_key)); + } + Some(weight) => { + changes.push(format!( + "added signer {} with weight {}", + short_key, weight + )); + } + None => { + changes.push(format!("modified signer {}", short_key)); + } + } + } + + let summary = build_summary(&account, &changes); + + SetOptionsExplanation { + summary, + account, + changes, + } +} + +/// Build the final summary string. +fn build_summary(account: &str, changes: &[String]) -> String { + if changes.is_empty() { + return format!( + "{} submitted a set_options operation with no recognised changes.", + account + ); + } + format!("{} updated their account: {}", account, join_changes(changes)) +} + +/// Join change descriptions into natural English. +/// 1 item → "a" +/// 2 items → "a and b" +/// 3+ → "a, b, and c" +fn join_changes(changes: &[String]) -> String { + match changes.len() { + 0 => String::new(), + 1 => changes[0].clone(), + 2 => format!("{} and {}", changes[0], changes[1]), + _ => { + let all_but_last = changes[..changes.len() - 1].join(", "); + format!("{}, and {}", all_but_last, changes[changes.len() - 1]) + } + } +} + +/// Translate Stellar account flag bitmasks into readable names. +/// AUTH_REQUIRED=1, AUTH_REVOCABLE=2, AUTH_IMMUTABLE=4, CLAWBACK_ENABLED=8 +fn describe_flags(flags: u32) -> String { + let mut names: Vec<&str> = Vec::new(); + if flags & 1 != 0 { names.push("AUTH_REQUIRED"); } + if flags & 2 != 0 { names.push("AUTH_REVOCABLE"); } + if flags & 4 != 0 { names.push("AUTH_IMMUTABLE"); } + if flags & 8 != 0 { names.push("CLAWBACK_ENABLED"); } + if names.is_empty() { + flags.to_string() + } else { + names.join(", ") + } +} + +/// Shorten a long Stellar key for display: "GABC...WXYZ" +fn shorten_key(key: &str) -> String { + if key.len() > 12 { + format!("{}...{}", &key[..4], &key[key.len() - 4..]) + } else { + key.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::operation::SetOptionsOperation; + + fn base_op() -> SetOptionsOperation { + SetOptionsOperation { + id: "op1".to_string(), + source_account: Some("GAAAA...ZZZZ".to_string()), + ..Default::default() + } + } + + // ── Single change ────────────────────────────────────────────────────── + + #[test] + fn test_single_home_domain() { + let op = SetOptionsOperation { + home_domain: Some("example.com".to_string()), + ..base_op() + }; + let result = explain_set_options(&op); + + assert_eq!(result.changes.len(), 1); + assert!(result.changes[0].contains("example.com")); + assert!(result.summary.contains("set home domain to example.com")); + assert!(result.summary.contains("updated their account")); + } + + #[test] + fn test_single_inflation_dest() { + let op = SetOptionsOperation { + inflation_dest: Some("GBBBBB...YYYY".to_string()), + ..base_op() + }; + let result = explain_set_options(&op); + + assert_eq!(result.changes.len(), 1); + assert!(result.changes[0].contains("inflation destination")); + } + + #[test] + fn test_single_master_weight() { + let op = SetOptionsOperation { + master_weight: Some(5), + ..base_op() + }; + let result = explain_set_options(&op); + + assert_eq!(result.changes.len(), 1); + assert!(result.changes[0].contains("master key weight to 5")); + } + + #[test] + fn test_master_weight_zero_disables_key() { + let op = SetOptionsOperation { + master_weight: Some(0), + ..base_op() + }; + let result = explain_set_options(&op); + + assert_eq!(result.changes.len(), 1); + assert!(result.changes[0].contains("disabled the master key")); + } + + #[test] + fn test_home_domain_cleared() { + let op = SetOptionsOperation { + home_domain: Some("".to_string()), + ..base_op() + }; + let result = explain_set_options(&op); + + assert_eq!(result.changes.len(), 1); + assert!(result.changes[0].contains("cleared the home domain")); + } + + // ── Signer tests ─────────────────────────────────────────────────────── + + #[test] + fn test_signer_added() { + let op = SetOptionsOperation { + signer_key: Some("GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB".to_string()), + signer_weight: Some(1), + ..base_op() + }; + let result = explain_set_options(&op); + + assert_eq!(result.changes.len(), 1); + assert!(result.changes[0].contains("added signer")); + assert!(result.changes[0].contains("weight 1")); + } + + #[test] + fn test_signer_removed_weight_zero() { + let op = SetOptionsOperation { + signer_key: Some("GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC".to_string()), + signer_weight: Some(0), + ..base_op() + }; + let result = explain_set_options(&op); + + assert_eq!(result.changes.len(), 1); + assert!(result.changes[0].contains("removed signer")); + } + + // ── Multiple changes ─────────────────────────────────────────────────── + + #[test] + fn test_two_changes_joined_with_and() { + let op = SetOptionsOperation { + home_domain: Some("stellar.org".to_string()), + low_threshold: Some(1), + ..base_op() + }; + let result = explain_set_options(&op); + + assert_eq!(result.changes.len(), 2); + assert!(result.summary.contains(" and ")); + } + + #[test] + fn test_three_changes_uses_oxford_comma() { + let op = SetOptionsOperation { + home_domain: Some("example.com".to_string()), + low_threshold: Some(1), + med_threshold: Some(2), + ..base_op() + }; + let result = explain_set_options(&op); + + assert_eq!(result.changes.len(), 3); + assert!(result.summary.contains(", and ")); + } + + #[test] + fn test_signer_add_plus_home_domain() { + let op = SetOptionsOperation { + home_domain: Some("example.com".to_string()), + signer_key: Some("GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB".to_string()), + signer_weight: Some(1), + ..base_op() + }; + let result = explain_set_options(&op); + + assert_eq!(result.changes.len(), 2); + assert!(result.summary.contains("set home domain to example.com")); + assert!(result.summary.contains("added signer")); + } + + // ── Edge cases ───────────────────────────────────────────────────────── + + #[test] + fn test_empty_set_options() { + let op = SetOptionsOperation { + id: "op1".to_string(), + source_account: Some("GAAAA".to_string()), + ..Default::default() + }; + let result = explain_set_options(&op); + + assert_eq!(result.changes.len(), 0); + assert!(result.summary.contains("no recognised changes")); + } + + #[test] + fn test_set_flags_auth_required() { + let op = SetOptionsOperation { + set_flags: Some(1), + ..base_op() + }; + let result = explain_set_options(&op); + + assert!(result.changes[0].contains("AUTH_REQUIRED")); + assert!(result.changes[0].contains("enabled")); + } + + #[test] + fn test_clear_flags_auth_revocable() { + let op = SetOptionsOperation { + clear_flags: Some(2), + ..base_op() + }; + let result = explain_set_options(&op); + + assert!(result.changes[0].contains("AUTH_REVOCABLE")); + assert!(result.changes[0].contains("disabled")); + } + + #[test] + fn test_unknown_account_fallback() { + let op = SetOptionsOperation { + source_account: None, + home_domain: Some("test.com".to_string()), + ..Default::default() + }; + let result = explain_set_options(&op); + + assert_eq!(result.account, "Unknown"); + assert!(result.summary.starts_with("Unknown")); + } + + #[test] + fn test_account_field_in_result() { + let op = SetOptionsOperation { + source_account: Some("GSPECIFIC".to_string()), + home_domain: Some("test.com".to_string()), + ..Default::default() + }; + let result = explain_set_options(&op); + + assert_eq!(result.account, "GSPECIFIC"); + assert!(result.summary.starts_with("GSPECIFIC")); + } +} \ No newline at end of file diff --git a/packages/core/src/explain/transaction.rs b/packages/core/src/explain/transaction.rs index c89586c..a30fa3d 100644 --- a/packages/core/src/explain/transaction.rs +++ b/packages/core/src/explain/transaction.rs @@ -1,6 +1,4 @@ //! Transaction explanation orchestration. -//! -//! Accepts an internal transaction model and produces structured explanations. use serde::{Deserialize, Serialize}; @@ -8,11 +6,7 @@ use crate::explain::memo::explain_memo; use crate::models::fee::FeeStats; use crate::models::transaction::Transaction; -use super::operation::payment::{ - explain_payment, - explain_payment_with_fee, - PaymentExplanation, -}; +use super::operation::payment::{explain_payment, explain_payment_with_fee, PaymentExplanation}; /// Complete explanation of a transaction. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -22,20 +16,14 @@ pub struct TransactionExplanation { pub summary: String, pub payment_explanations: Vec, pub skipped_operations: usize, - /// Human-readable explanation of the transaction memo. - /// None if the transaction has no memo. pub memo_explanation: Option, - /// Human-readable explanation of transaction fee context. pub fee_explanation: Option, } -/// Result type for transaction explanation. pub type ExplainResult = Result; -/// Errors that can occur during explanation. #[derive(Debug, Clone, PartialEq)] pub enum ExplainError { - /// The transaction has zero operations (truly empty). EmptyTransaction, } @@ -52,12 +40,8 @@ impl std::fmt::Display for ExplainError { impl std::error::Error for ExplainError {} /// Produce a plain-English fee explanation. -/// -/// Uses FeeStats to contextualise whether the fee is standard or elevated. -/// Falls back to a simple message if fee_stats is None. pub fn explain_fee(fee_charged: u64, fee_stats: Option<&FeeStats>) -> String { let xlm = FeeStats::stroops_to_xlm(fee_charged); - match fee_stats { None => format!("A fee of {} XLM was charged.", xlm), Some(stats) => { @@ -192,14 +176,10 @@ mod tests { operations: vec![create_payment_operation("1", "50.0")], memo: Some(Memo::text("Invoice #12345").unwrap()), }; - let explanation = explain_transaction(&tx, None).unwrap(); assert_eq!(explanation.transaction_hash, "abc123"); assert!(explanation.memo_explanation.is_some()); - assert!(explanation - .memo_explanation - .unwrap() - .contains("Invoice #12345")); + assert!(explanation.memo_explanation.unwrap().contains("Invoice #12345")); assert!(explanation.fee_explanation.is_some()); } @@ -212,7 +192,6 @@ mod tests { operations: vec![create_other_operation("1"), create_other_operation("2")], memo: None, }; - let result = explain_transaction(&tx, None); assert!(result.is_ok()); let explanation = result.unwrap(); @@ -229,7 +208,6 @@ mod tests { operations: vec![], memo: None, }; - assert!(explain_transaction(&tx, None).is_err()); } @@ -238,4 +216,4 @@ mod tests { let summary = build_transaction_summary(false, 1, 0); assert_eq!(summary, "This failed transaction contains 1 payment."); } -} +} \ No newline at end of file diff --git a/packages/core/src/main.rs b/packages/core/src/main.rs index 44bea61..e7d9de1 100644 --- a/packages/core/src/main.rs +++ b/packages/core/src/main.rs @@ -5,9 +5,10 @@ mod explain; mod routes; mod config; mod middleware; +mod state; use axum::{ - middleware, + middleware as axum_middleware, Router, routing::get, response::{IntoResponse, Response}, @@ -23,7 +24,10 @@ use tower_http::cors::{CorsLayer, AllowOrigin}; use tower_governor::{ governor::GovernorConfigBuilder, GovernorLayer, + key_extractor::PeerIpKeyExtractor, }; +use governor::middleware::NoOpMiddleware; +use utoipa::OpenApi; use utoipa_swagger_ui::SwaggerUi; use tracing_subscriber::EnvFilter; @@ -33,26 +37,16 @@ use crate::services::horizon::HorizonClient; use crate::middleware::request_id::request_id_middleware; -fn rate_limit_layer() -> GovernorLayer { - let governor_conf = GovernorConfigBuilder::default() - .per_minute(60) - .burst_size(60) - .use_headers() - .finish() - .expect("failed to build rate limit config"); - - GovernorLayer { - config: std::sync::Arc::new(governor_conf), - error_handler: Some(Box::new(|_| { - Box::pin(async { - RateLimitError { - error: "rate_limited", - message: "Too many requests. Please retry later.", - } - .into_response() - }) - })), - } +fn rate_limit_layer() -> GovernorLayer { + let governor_conf = Arc::new( + GovernorConfigBuilder::default() + .per_second(1) + .burst_size(60) + .finish() + .expect("failed to build rate limit config"), + ); + + GovernorLayer { config: governor_conf } } #[derive(Serialize)] @@ -63,11 +57,7 @@ struct RateLimitError { impl IntoResponse for RateLimitError { fn into_response(self) -> Response { - ( - StatusCode::TOO_MANY_REQUESTS, - Json(self), - ) - .into_response() + (StatusCode::TOO_MANY_REQUESTS, Json(self)).into_response() } } @@ -151,7 +141,7 @@ async fn main() { .layer(cors) .layer( ServiceBuilder::new() - .layer(middleware::from_fn(request_id_middleware)) + .layer(axum_middleware::from_fn(request_id_middleware)) .layer(rate_limit_layer()) ); @@ -160,4 +150,4 @@ async fn main() { let listener = TcpListener::bind(addr).await.unwrap(); axum::serve(listener, app).await.unwrap(); -} +} \ No newline at end of file diff --git a/packages/core/src/models/operation.rs b/packages/core/src/models/operation.rs index 121af62..87da76c 100644 --- a/packages/core/src/models/operation.rs +++ b/packages/core/src/models/operation.rs @@ -2,21 +2,28 @@ //! //! Internal representation of Stellar operations, independent of Horizon JSON. +use crate::models::memo::Memo; use serde::{Deserialize, Serialize}; -use crate::services::horizon::{HorizonOperation, HorizonPathAsset}; +#[derive(Debug, Clone)] +pub struct Transaction { + pub hash: String, + pub successful: bool, + pub fee_charged: u64, + pub operations: Vec, + pub memo: Memo, +} /// Represents a Stellar operation. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(tag = "type", rename_all = "snake_case")] pub enum Operation { Payment(PaymentOperation), - ManageSellOffer(ManageOfferOperation), - ManageBuyOffer(ManageOfferOperation), - PathPaymentStrictSend(PathPaymentOperation), - PathPaymentStrictReceive(PathPaymentOperation), - ChangeTrust(ChangeTrustOperation), + SetOptions(SetOptionsOperation), CreateAccount(CreateAccountOperation), + ChangeTrust(ChangeTrustOperation), + ManageOffer(ManageOfferOperation), + PathPayment(PathPaymentOperation), Other(OtherOperation), } @@ -32,12 +39,57 @@ pub struct PaymentOperation { pub amount: String, } +/// A set_options operation that configures account settings. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct SetOptionsOperation { + pub id: String, + pub source_account: Option, + pub inflation_dest: Option, + pub clear_flags: Option, + pub set_flags: Option, + pub master_weight: Option, + pub low_threshold: Option, + pub med_threshold: Option, + pub high_threshold: Option, + pub home_domain: Option, + pub signer_key: Option, + pub signer_weight: Option, +} + +/// A create_account operation that funds and activates a new Stellar account. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct CreateAccountOperation { + pub id: String, + /// The account that funds the new account. + pub funder: String, + /// The newly created account address. + pub new_account: String, + /// Starting XLM balance sent to the new account. + pub starting_balance: String, +} + +/// A change_trust operation that adds, updates, or removes a trust line. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ChangeTrustOperation { + pub id: String, + /// The account opting in or out of holding the asset. + pub trustor: String, + pub asset_code: String, + pub asset_issuer: String, + /// Trust limit. "0" means remove the trust line. + pub limit: String, +} + +/// Whether the offer intends to sell or buy the named asset. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] pub enum OfferType { Sell, Buy, } +/// A manage_offer (or manage_buy_offer) operation that creates, updates, or +/// cancels an order on the Stellar DEX. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct ManageOfferOperation { pub id: String, @@ -46,16 +98,20 @@ pub struct ManageOfferOperation { pub buying_asset: String, pub amount: String, pub price: String, + /// 0 = new offer, non-zero = update or cancel. pub offer_id: u64, pub offer_type: OfferType, } +/// Whether the path payment fixes the send amount or the receive amount. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] pub enum PathPaymentType { StrictSend, StrictReceive, } +/// A path_payment_strict_send or path_payment_strict_receive operation. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct PathPaymentOperation { pub id: String, @@ -65,30 +121,12 @@ pub struct PathPaymentOperation { pub send_amount: String, pub dest_asset: String, pub dest_amount: String, + /// Intermediate assets in the conversion path. pub path: Vec, pub payment_type: PathPaymentType, } -/// A create_account operation that funds and activates a new Stellar account. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct CreateAccountOperation { - pub id: String, - pub funder: String, - pub new_account: String, - pub starting_balance: String, -} - -/// A change_trust operation that opts an account in or out of holding a non-native asset. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct ChangeTrustOperation { - pub id: String, - pub trustor: String, - pub asset_code: String, - pub asset_issuer: String, - pub limit: String, -} - -/// Placeholder for non-supported operation types. +/// Placeholder for operation types we do not yet explain. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct OtherOperation { pub id: String, @@ -96,156 +134,174 @@ pub struct OtherOperation { } impl Operation { - /// Returns true if this operation is a payment. pub fn is_payment(&self) -> bool { matches!(self, Operation::Payment(_)) } - /// Returns true if this operation is a change_trust. - pub fn is_change_trust(&self) -> bool { - matches!(self, Operation::ChangeTrust(_)) - } - - /// Returns true if this operation is a create_account. - pub fn is_create_account(&self) -> bool { - matches!(self, Operation::CreateAccount(_)) + pub fn is_set_options(&self) -> bool { + matches!(self, Operation::SetOptions(_)) } - /// Returns the operation ID. pub fn id(&self) -> &str { match self { Operation::Payment(p) => &p.id, - Operation::ManageSellOffer(o) => &o.id, - Operation::ManageBuyOffer(o) => &o.id, - Operation::PathPaymentStrictSend(p) => &p.id, - Operation::PathPaymentStrictReceive(p) => &p.id, + Operation::SetOptions(s) => &s.id, + Operation::CreateAccount(c) => &c.id, Operation::ChangeTrust(c) => &c.id, - Operation::CreateAccount(ca) => &ca.id, + Operation::ManageOffer(m) => &m.id, + Operation::PathPayment(p) => &p.id, Operation::Other(o) => &o.id, } } } -fn format_asset( - asset_type: Option, - asset_code: Option, - asset_issuer: Option, -) -> String { - match asset_type.as_deref() { - Some("native") => "XLM (native)".to_string(), +use crate::services::horizon::HorizonOperation; + +/// Format an asset from Horizon's separate code/issuer/type fields into +/// a single display string: "XLM (native)" or "USDC (GISSUER...)". +fn format_asset(asset_type: Option<&str>, asset_code: Option<&str>, asset_issuer: Option<&str>) -> String { + match asset_type { + Some("native") | None => "XLM (native)".to_string(), _ => match (asset_code, asset_issuer) { (Some(code), Some(issuer)) => format!("{} ({})", code, issuer), - (Some(code), None) => code, + (Some(code), None) => code.to_string(), _ => "Unknown".to_string(), }, } } -fn format_offer_asset( - asset_type: Option, - asset_code: Option, - asset_issuer: Option, -) -> String { - format_asset(asset_type, asset_code, asset_issuer) -} - -fn format_path(path: Option>) -> Vec { - path.unwrap_or_default() - .into_iter() - .map(|asset| format_asset(Some(asset.asset_type), asset.asset_code, asset.asset_issuer)) - .collect() -} - impl From for Operation { fn from(op: HorizonOperation) -> Self { - let op_type = op.type_i.clone(); - - match op_type.as_str() { + match op.type_i.as_str() { "payment" => Operation::Payment(PaymentOperation { id: op.id, - source_account: op.from.or(op.source_account), + source_account: op.from.clone().or(op.source_account.clone()), destination: op.to.unwrap_or_default(), asset_type: op.asset_type.unwrap_or_else(|| "native".to_string()), asset_code: op.asset_code, asset_issuer: op.asset_issuer, amount: op.amount.unwrap_or_else(|| "0".to_string()), }), - "manage_sell_offer" => Operation::ManageSellOffer(ManageOfferOperation { + "set_options" => Operation::SetOptions(SetOptionsOperation { id: op.id, - seller: op.source_account.unwrap_or_default(), - selling_asset: format_offer_asset( - op.selling_asset_type, - op.selling_asset_code, - op.selling_asset_issuer, - ), - buying_asset: format_offer_asset( - op.buying_asset_type, - op.buying_asset_code, - op.buying_asset_issuer, - ), - amount: op.amount.unwrap_or_else(|| "0".to_string()), - price: op.price.unwrap_or_default(), - offer_id: op.offer_id.unwrap_or(0), - offer_type: OfferType::Sell, + source_account: op.source_account, + inflation_dest: op.inflation_dest, + clear_flags: op.clear_flags, + set_flags: op.set_flags, + master_weight: op.master_weight, + low_threshold: op.low_threshold, + med_threshold: op.med_threshold, + high_threshold: op.high_threshold, + home_domain: op.home_domain, + signer_key: op.signer_key, + signer_weight: op.signer_weight, }), - "manage_buy_offer" => Operation::ManageBuyOffer(ManageOfferOperation { + "create_account" => Operation::CreateAccount(CreateAccountOperation { id: op.id, - seller: op.source_account.unwrap_or_default(), - selling_asset: format_offer_asset( - op.selling_asset_type, - op.selling_asset_code, - op.selling_asset_issuer, - ), - buying_asset: format_offer_asset( - op.buying_asset_type, - op.buying_asset_code, - op.buying_asset_issuer, - ), - amount: op.amount.unwrap_or_else(|| "0".to_string()), - price: op.price.unwrap_or_default(), - offer_id: op.offer_id.unwrap_or(0), - offer_type: OfferType::Buy, + funder: op.funder.unwrap_or_else(|| "Unknown".to_string()), + new_account: op.account.unwrap_or_default(), + starting_balance: op.starting_balance.unwrap_or_else(|| "0".to_string()), }), - "path_payment_strict_send" => Operation::PathPaymentStrictSend(PathPaymentOperation { + "change_trust" => Operation::ChangeTrust(ChangeTrustOperation { id: op.id, - source_account: op.from.or(op.source_account), - destination: op.to.unwrap_or_default(), - send_asset: format_asset(op.source_asset_type, op.source_asset_code, op.source_asset_issuer), - send_amount: op.source_amount.unwrap_or_else(|| "0".to_string()), - dest_asset: format_asset(op.asset_type, op.asset_code, op.asset_issuer), - dest_amount: op.amount.unwrap_or_else(|| "0".to_string()), - path: format_path(op.path), - payment_type: PathPaymentType::StrictSend, + trustor: op.source_account.unwrap_or_else(|| "Unknown".to_string()), + asset_code: op.asset_code.unwrap_or_default(), + asset_issuer: op.asset_issuer.unwrap_or_default(), + limit: op.limit.unwrap_or_else(|| "0".to_string()), }), + "manage_offer" | "manage_sell_offer" => { + let selling = format_asset( + op.selling_asset_type.as_deref(), + op.selling_asset_code.as_deref(), + op.selling_asset_issuer.as_deref(), + ); + let buying = format_asset( + op.buying_asset_type.as_deref(), + op.buying_asset_code.as_deref(), + op.buying_asset_issuer.as_deref(), + ); + Operation::ManageOffer(ManageOfferOperation { + id: op.id, + seller: op.source_account.unwrap_or_else(|| "Unknown".to_string()), + selling_asset: selling, + buying_asset: buying, + amount: op.amount.unwrap_or_else(|| "0".to_string()), + price: op.price.unwrap_or_default(), + offer_id: op.offer_id.unwrap_or(0), + offer_type: OfferType::Sell, + }) + } + "manage_buy_offer" => { + let selling = format_asset( + op.selling_asset_type.as_deref(), + op.selling_asset_code.as_deref(), + op.selling_asset_issuer.as_deref(), + ); + let buying = format_asset( + op.buying_asset_type.as_deref(), + op.buying_asset_code.as_deref(), + op.buying_asset_issuer.as_deref(), + ); + Operation::ManageOffer(ManageOfferOperation { + id: op.id, + seller: op.source_account.unwrap_or_else(|| "Unknown".to_string()), + selling_asset: selling, + buying_asset: buying, + amount: op.amount.unwrap_or_else(|| "0".to_string()), + price: op.price.unwrap_or_default(), + offer_id: op.offer_id.unwrap_or(0), + offer_type: OfferType::Buy, + }) + } + "path_payment_strict_send" => { + let send_asset = format_asset( + op.source_asset_type.as_deref(), + op.source_asset_code.as_deref(), + op.source_asset_issuer.as_deref(), + ); + let dest_asset = format_asset( + op.asset_type.as_deref(), + op.asset_code.as_deref(), + op.asset_issuer.as_deref(), + ); + Operation::PathPayment(PathPaymentOperation { + id: op.id, + source_account: op.from.clone().or(op.source_account.clone()), + destination: op.to.unwrap_or_default(), + send_asset, + send_amount: op.source_amount.unwrap_or_else(|| "0".to_string()), + dest_asset, + dest_amount: op.amount.unwrap_or_else(|| "0".to_string()), + path: op.path.unwrap_or_default(), + payment_type: PathPaymentType::StrictSend, + }) + } "path_payment_strict_receive" => { - Operation::PathPaymentStrictReceive(PathPaymentOperation { + let send_asset = format_asset( + op.source_asset_type.as_deref(), + op.source_asset_code.as_deref(), + op.source_asset_issuer.as_deref(), + ); + let dest_asset = format_asset( + op.asset_type.as_deref(), + op.asset_code.as_deref(), + op.asset_issuer.as_deref(), + ); + Operation::PathPayment(PathPaymentOperation { id: op.id, - source_account: op.from.or(op.source_account), + source_account: op.from.clone().or(op.source_account.clone()), destination: op.to.unwrap_or_default(), - send_asset: format_asset(op.source_asset_type, op.source_asset_code, op.source_asset_issuer), + send_asset, send_amount: op.source_amount.unwrap_or_else(|| "0".to_string()), - dest_asset: format_asset(op.asset_type, op.asset_code, op.asset_issuer), + dest_asset, dest_amount: op.amount.unwrap_or_else(|| "0".to_string()), - path: format_path(op.path), + path: op.path.unwrap_or_default(), payment_type: PathPaymentType::StrictReceive, }) } - "change_trust" => Operation::ChangeTrust(ChangeTrustOperation { - id: op.id, - trustor: op.trustor.unwrap_or_default(), - asset_code: op.asset_code.unwrap_or_default(), - asset_issuer: op.asset_issuer.unwrap_or_default(), - limit: op.limit.unwrap_or_else(|| "0".to_string()), - }), - "create_account" => Operation::CreateAccount(CreateAccountOperation { - id: op.id, - funder: op.funder.unwrap_or_default(), - new_account: op.account.unwrap_or_default(), - starting_balance: op.starting_balance.unwrap_or_else(|| "0".to_string()), - }), _ => Operation::Other(OtherOperation { id: op.id, - operation_type: op_type, + operation_type: op.type_i, }), } } @@ -266,16 +322,25 @@ mod tests { asset_issuer: None, amount: "100.0".to_string(), }); - let other = Operation::Other(OtherOperation { id: "67890".to_string(), operation_type: "create_account".to_string(), }); - assert!(payment.is_payment()); assert!(!other.is_payment()); } + #[test] + fn test_is_set_options() { + let set_opts = Operation::SetOptions(SetOptionsOperation { + id: "op1".to_string(), + home_domain: Some("example.com".to_string()), + ..Default::default() + }); + assert!(set_opts.is_set_options()); + assert!(!set_opts.is_payment()); + } + #[test] fn test_operation_id() { let payment = Operation::Payment(PaymentOperation { @@ -287,7 +352,50 @@ mod tests { asset_issuer: None, amount: "100.0".to_string(), }); - assert_eq!(payment.id(), "12345"); } -} + + #[test] + fn test_set_options_id() { + let op = Operation::SetOptions(SetOptionsOperation { + id: "set-op-99".to_string(), + ..Default::default() + }); + assert_eq!(op.id(), "set-op-99"); + } + + #[test] + fn test_create_account_id() { + let op = Operation::CreateAccount(CreateAccountOperation { + id: "ca-1".to_string(), + funder: "GFUNDER".to_string(), + new_account: "GNEW".to_string(), + starting_balance: "100".to_string(), + }); + assert_eq!(op.id(), "ca-1"); + } + + #[test] + fn test_change_trust_id() { + let op = Operation::ChangeTrust(ChangeTrustOperation { + id: "ct-1".to_string(), + trustor: "GTRUSTEE".to_string(), + asset_code: "USDC".to_string(), + asset_issuer: "GISSUER".to_string(), + limit: "1000".to_string(), + }); + assert_eq!(op.id(), "ct-1"); + } + + #[test] + fn test_format_asset_native() { + let result = format_asset(Some("native"), None, None); + assert_eq!(result, "XLM (native)"); + } + + #[test] + fn test_format_asset_credit() { + let result = format_asset(Some("credit_alphanum4"), Some("USDC"), Some("GISSUER")); + assert_eq!(result, "USDC (GISSUER)"); + } +} \ No newline at end of file diff --git a/packages/core/src/services/explain.rs b/packages/core/src/services/explain.rs index b668d2d..cd893e9 100644 --- a/packages/core/src/services/explain.rs +++ b/packages/core/src/services/explain.rs @@ -23,21 +23,12 @@ pub fn map_transaction_to_domain( fn map_memo(memo_type: Option<&str>, memo_value: Option<&str>) -> Option { match memo_type { None | Some("none") => None, - Some("text") => { - memo_value.and_then(|v| Memo::text(v)) - } - Some("id") => { - memo_value - .and_then(|v| v.parse::().ok()) - .map(Memo::id) - } - Some("hash") => { - memo_value.map(|v| Memo::hash(v)) - } - Some("return") => { - memo_value.map(|v| Memo::return_hash(v)) - } - // Unknown memo type — treat as no memo rather than crashing + Some("text") => memo_value.and_then(|v| Memo::text(v)), + Some("id") => memo_value + .and_then(|v| v.parse::().ok()) + .map(Memo::id), + Some("hash") => memo_value.map(|v| Memo::hash(v)), + Some("return") => memo_value.map(|v| Memo::return_hash(v)), Some(_) => None, } } @@ -86,7 +77,6 @@ mod tests { #[test] fn test_map_memo_unknown_type() { - // Unknown types should degrade gracefully, not crash assert_eq!(map_memo(Some("unknown_future_type"), Some("value")), None); } } \ No newline at end of file diff --git a/packages/core/src/services/horizon.rs b/packages/core/src/services/horizon.rs index 50d6eae..e2b2c17 100644 --- a/packages/core/src/services/horizon.rs +++ b/packages/core/src/services/horizon.rs @@ -1,16 +1,12 @@ use reqwest::Client; use serde::Deserialize; -use std::{ - collections::HashMap, - sync::{Mutex, OnceLock}, - time::Duration, -}; +use std::collections::HashMap; +use std::sync::{Arc, RwLock}; +use std::time::{Duration, Instant}; use crate::errors::HorizonError; use crate::models::fee::FeeStats; -use crate::services::transaction_cache::{CacheKey, Network, TransactionCache}; -/// Raw transaction response from the Horizon API. #[derive(Debug, Deserialize, Clone)] pub struct HorizonTransaction { pub hash: String, @@ -20,29 +16,23 @@ pub struct HorizonTransaction { pub memo: Option, } -/// Raw fee_stats response from Horizon. -/// https://developers.stellar.org/api/horizon/aggregations/fee-stats -#[derive(Debug, Deserialize)] -struct HorizonFeeStats { - last_ledger_base_fee: String, - fee_charged: HorizonFeeCharged, -} - -#[derive(Debug, Deserialize)] -struct HorizonFeeCharged { - min: String, - max: String, - mode: String, - p90: String, +#[derive(Debug, Deserialize, Clone)] +pub struct HorizonAccountTransaction { + pub hash: String, + pub successful: bool, + pub created_at: String, + pub source_account: Option, + pub operation_count: u32, + pub memo_type: Option, + pub memo: Option, } -static STELLAR_TOML_ORG_CACHE: OnceLock>>> = OnceLock::new(); - #[derive(Clone)] pub struct HorizonClient { client: Client, base_url: String, - cache: TransactionCache, + /// Simple in-memory cache for stellar.toml lookups keyed by domain. + toml_cache: Arc, Instant)>>>, } impl HorizonClient { @@ -50,7 +40,7 @@ impl HorizonClient { Self { client: Client::new(), base_url: base_url.into(), - cache: TransactionCache::with_default_ttl(), + toml_cache: Arc::new(RwLock::new(HashMap::new())), } } @@ -58,12 +48,6 @@ impl HorizonClient { &self, hash: &str, ) -> Result { - let cache_key = CacheKey::new(hash.to_string(), Network::Testnet); - - if let Some(cached) = self.cache.get(&cache_key) { - return Ok(cached); - } - let url = format!("{}/transactions/{}", self.base_url, hash); let res = self @@ -73,18 +57,14 @@ impl HorizonClient { .await .map_err(|_| HorizonError::NetworkError)?; - let tx = match res.status().as_u16() { + match res.status().as_u16() { 200 => res .json::() .await - .map_err(|_| HorizonError::InvalidResponse)?, - 404 => return Err(HorizonError::TransactionNotFound), - _ => return Err(HorizonError::InvalidResponse), - }; - - self.cache.insert(cache_key, tx.clone()); - - Ok(tx) + .map_err(|_| HorizonError::InvalidResponse), + 404 => Err(HorizonError::TransactionNotFound), + _ => Err(HorizonError::InvalidResponse), + } } pub async fn fetch_operations( @@ -113,6 +93,43 @@ impl HorizonClient { } } + /// Fetch the current network fee stats from Horizon. + /// Returns None if the request fails — callers degrade gracefully. + pub async fn fetch_fee_stats(&self) -> Option { + let url = format!("{}/fee_stats", self.base_url); + + let res = self.client.get(url).send().await.ok()?; + + if res.status().as_u16() != 200 { + return None; + } + + let raw: HorizonFeeStats = res.json().await.ok()?; + + let base_fee = raw.last_ledger_base_fee.parse::().ok()?; + let min_fee = raw.fee_charged.min.parse::().unwrap_or(base_fee); + let max_fee = raw.fee_charged.max.parse::().unwrap_or(base_fee); + let mode_fee = raw.fee_charged.mode.parse::().unwrap_or(base_fee); + let p90_fee = raw.fee_charged.p90.parse::().unwrap_or(base_fee); + + Some(FeeStats::new(base_fee, min_fee, max_fee, mode_fee, p90_fee)) + } + + /// Check whether Horizon is reachable by hitting the root endpoint. + pub async fn is_reachable(&self) -> bool { + let url = format!("{}/", self.base_url); + self.client + .get(url) + .timeout(Duration::from_secs(5)) + .send() + .await + .map(|r| r.status().as_u16() < 500) + .unwrap_or(false) + } + + /// Fetch paginated transactions for an account. + /// + /// Returns `(records, next_cursor, prev_cursor)`. pub async fn fetch_account_transactions( &self, address: &str, @@ -137,161 +154,135 @@ impl HorizonClient { match res.status().as_u16() { 200 => { - let wrapper: HorizonAccountTransactionsResponse = - res.json().await.map_err(|_| HorizonError::InvalidResponse)?; - let next_cursor = wrapper.links.next.as_ref().and_then(|l| extract_cursor(&l.href)); - let prev_cursor = wrapper.links.prev.as_ref().and_then(|l| extract_cursor(&l.href)); - Ok((wrapper.embedded.records, next_cursor, prev_cursor)) + let wrapper: HorizonAccountTransactionsResponse = res + .json() + .await + .map_err(|_| HorizonError::InvalidResponse)?; + + let next_cursor = extract_cursor( + wrapper._links.next.as_ref().and_then(|l| l.href.as_deref()), + ); + let prev_cursor = extract_cursor( + wrapper._links.prev.as_ref().and_then(|l| l.href.as_deref()), + ); + + Ok((wrapper._embedded.records, next_cursor, prev_cursor)) } 404 => Err(HorizonError::AccountNotFound), _ => Err(HorizonError::InvalidResponse), } } - /// Resolve ORG_NAME from a domain's stellar.toml file. - /// - /// Uses an in-memory cache for the process lifetime and applies a hard timeout - /// so this call never blocks explanation responses for long. - pub async fn fetch_stellar_toml_org_name(&self, home_domain: &str) -> Option { - let cache_key = normalize_cache_key(home_domain)?; - - if let Some(cached) = lookup_stellar_toml_cache(&cache_key) { - return cached; + /// Fetch ORG_NAME from a domain's stellar.toml. + /// Results are cached in memory for 10 minutes per domain. + pub async fn fetch_stellar_toml_org_name(&self, domain: &str) -> Option { + // Check cache first + { + let cache = self.toml_cache.read().unwrap(); + if let Some((cached_value, cached_at)) = cache.get(domain) { + if cached_at.elapsed() < Duration::from_secs(600) { + return cached_value.clone(); + } + } } - let url = match build_stellar_toml_url(home_domain) { - Some(url) => url, - None => { - store_stellar_toml_cache(cache_key, None); - return None; - } - }; + // Fetch from domain + let url = format!("{}/.well-known/stellar.toml", domain); + let result = self.client.get(&url).send().await.ok(); - let response = match self - .client - .get(url) - .timeout(Duration::from_secs(3)) - .send() - .await - { - Ok(response) => response, - Err(_) => { - store_stellar_toml_cache(cache_key, None); - return None; + let org_name = if let Some(res) = result { + if res.status().as_u16() == 200 { + let body = res.text().await.ok().unwrap_or_default(); + parse_org_name(&body) + } else { + None } + } else { + None }; - if !response.status().is_success() { - store_stellar_toml_cache(cache_key, None); - return None; + // Store in cache + { + let mut cache = self.toml_cache.write().unwrap(); + cache.insert(domain.to_string(), (org_name.clone(), Instant::now())); } - let body = match response.text().await { - Ok(body) => body, - Err(_) => { - store_stellar_toml_cache(cache_key, None); - return None; - } - }; - let org_name = parse_org_name_from_stellar_toml(&body); - store_stellar_toml_cache(cache_key, org_name.clone()); - org_name } +} - /// Fetch current network fee statistics from Horizon. - /// Returns None if the request fails — callers should degrade gracefully. - pub async fn fetch_fee_stats(&self) -> Option { - let url = format!("{}/fee_stats", self.base_url); - - let res = self.client.get(url).send().await.ok()?; +/// Extract `cursor` query param value from a Horizon pagination href. +fn extract_cursor(href: Option<&str>) -> Option { + let href = href?; + let url = reqwest::Url::parse(href).ok()?; + url.query_pairs() + .find(|(k, _)| k == "cursor") + .map(|(_, v)| v.into_owned()) +} - if res.status().as_u16() != 200 { - return None; +/// Parse ORG_NAME from a stellar.toml body. +/// Handles both `ORG_NAME="Foo"` and `ORG_NAME = "Foo"` formats. +fn parse_org_name(toml: &str) -> Option { + for line in toml.lines() { + let trimmed = line.trim(); + if trimmed.starts_with("ORG_NAME") { + if let Some(pos) = trimmed.find('=') { + let value = trimmed[pos + 1..].trim().trim_matches('"').to_string(); + if !value.is_empty() { + return Some(value); + } + } } - - let raw: HorizonFeeStats = res.json().await.ok()?; - - // Horizon returns fees as string stroops — parse each field - let base_fee = raw.last_ledger_base_fee.parse::().ok()?; - let min_fee = raw.fee_charged.min.parse::().ok()?; - let max_fee = raw.fee_charged.max.parse::().ok()?; - let mode_fee = raw.fee_charged.mode.parse::().ok()?; - let p90_fee = raw.fee_charged.p90.parse::().ok()?; - - Some(FeeStats { - base_fee, - min_fee, - max_fee, - mode_fee, - p90_fee, - }) - } - - /// Lightweight connectivity check for health endpoint - pub async fn is_reachable(&self) -> bool { - let res = self - .client - .head(&self.base_url) - .timeout(Duration::from_secs(2)) - .send() - .await; - - matches!(res, Ok(r) if r.status().is_success()) } + None } +// ── Horizon JSON shapes ──────────────────────────────────────────────────── + #[derive(Debug, Deserialize)] -struct HorizonTransactionLink { - href: String, +struct HorizonOperationsResponse { + _embedded: HorizonEmbeddedOperations, } #[derive(Debug, Deserialize)] -struct HorizonTransactionLinks { - next: Option, - prev: Option, +struct HorizonEmbeddedOperations { + records: Vec, } #[derive(Debug, Deserialize)] struct HorizonAccountTransactionsResponse { - #[serde(rename = "_links")] - links: HorizonTransactionLinks, - #[serde(rename = "_embedded")] - embedded: HorizonAccountTransactionsEmbedded, + _links: HorizonLinks, + _embedded: HorizonEmbeddedAccountTransactions, } #[derive(Debug, Deserialize)] -struct HorizonAccountTransactionsEmbedded { - records: Vec, +struct HorizonLinks { + next: Option, + prev: Option, } -#[derive(Debug, Deserialize, Clone)] -pub struct HorizonAccountTransaction { - pub hash: String, - pub successful: bool, - pub created_at: String, - pub source_account: String, - pub operation_count: u32, - pub memo_type: Option, - pub memo: Option, +#[derive(Debug, Deserialize)] +struct HorizonLink { + href: Option, } #[derive(Debug, Deserialize)] -struct HorizonOperationsResponse { - _embedded: HorizonEmbeddedOperations, +struct HorizonEmbeddedAccountTransactions { + records: Vec, } #[derive(Debug, Deserialize)] -struct HorizonEmbeddedOperations { - records: Vec, +struct HorizonFeeStats { + last_ledger_base_fee: String, + fee_charged: HorizonFeeCharged, } -#[derive(Debug, Deserialize, Clone)] -pub struct HorizonPathAsset { - #[serde(rename = "asset_type")] - pub asset_type: String, - pub asset_code: Option, - pub asset_issuer: Option, +#[derive(Debug, Deserialize)] +struct HorizonFeeCharged { + min: String, + max: String, + mode: String, + p90: String, } #[derive(Debug, Deserialize)] @@ -300,13 +291,37 @@ pub struct HorizonOperation { pub transaction_hash: String, #[serde(rename = "type")] pub type_i: String, - pub source_account: Option, + + // Shared / payment pub from: Option, pub to: Option, pub asset_type: Option, pub asset_code: Option, pub asset_issuer: Option, pub amount: Option, + pub source_account: Option, + + // set_options + pub inflation_dest: Option, + pub clear_flags: Option, + pub set_flags: Option, + pub master_weight: Option, + pub low_threshold: Option, + pub med_threshold: Option, + pub high_threshold: Option, + pub home_domain: Option, + pub signer_key: Option, + pub signer_weight: Option, + + // create_account + pub funder: Option, + pub account: Option, + pub starting_balance: Option, + + // change_trust + pub limit: Option, + + // manage_offer / manage_buy_offer pub selling_asset_type: Option, pub selling_asset_code: Option, pub selling_asset_issuer: Option, @@ -315,74 +330,11 @@ pub struct HorizonOperation { pub buying_asset_issuer: Option, pub price: Option, pub offer_id: Option, + + // path_payment pub source_asset_type: Option, pub source_asset_code: Option, pub source_asset_issuer: Option, pub source_amount: Option, - pub path: Option>, - pub trustor: Option, - pub limit: Option, - pub funder: Option, - pub account: Option, - pub starting_balance: Option, -} - -fn extract_cursor(href: &str) -> Option { - href.split('?') - .nth(1)? - .split('&') - .find_map(|pair| { - let mut parts = pair.splitn(2, '='); - if parts.next()? == "cursor" { - parts.next().map(|v| v.to_string()) - } else { - None - } - }) -} - -fn stellar_toml_cache() -> &'static Mutex>> { - STELLAR_TOML_ORG_CACHE.get_or_init(|| Mutex::new(HashMap::new())) -} - -fn lookup_stellar_toml_cache(cache_key: &str) -> Option> { - let cache = stellar_toml_cache().lock().ok()?; - cache.get(cache_key).cloned() -} - -fn store_stellar_toml_cache(cache_key: String, org_name: Option) { - if let Ok(mut cache) = stellar_toml_cache().lock() { - cache.insert(cache_key, org_name); - } -} - -fn normalize_cache_key(home_domain: &str) -> Option { - let key = home_domain.trim().trim_end_matches('/').to_lowercase(); - if key.is_empty() { - None - } else { - Some(key) - } -} - -fn build_stellar_toml_url(home_domain: &str) -> Option { - let domain = home_domain.trim().trim_end_matches('/'); - if domain.is_empty() { - return None; - } - - if domain.starts_with("https://") || domain.starts_with("http://") { - Some(format!("{domain}/.well-known/stellar.toml")) - } else { - Some(format!("https://{domain}/.well-known/stellar.toml")) - } -} - -fn parse_org_name_from_stellar_toml(content: &str) -> Option { - let parsed: toml::Value = toml::from_str(content).ok()?; - parsed - .get("ORG_NAME") - .and_then(|value| value.as_str()) - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) -} + pub path: Option>, +} \ No newline at end of file diff --git a/packages/core/tests/integration.rs b/packages/core/tests/integration.rs index 6d3bbe6..0f00376 100644 --- a/packages/core/tests/integration.rs +++ b/packages/core/tests/integration.rs @@ -1 +1,2 @@ -mod integration; +// Integration tests live in tests/integration/mod.rs +// This file intentionally left empty to avoid module ambiguity. \ No newline at end of file diff --git a/packages/stellar_explain_current2.zip b/packages/stellar_explain_current2.zip new file mode 100644 index 0000000..055eaa2 Binary files /dev/null and b/packages/stellar_explain_current2.zip differ