From b8131529a1055f6f517da0b9f736039fb3027ddf Mon Sep 17 00:00:00 2001 From: Akanimoh12 Date: Wed, 29 Apr 2026 11:32:36 +0100 Subject: [PATCH] feat(backend): implement Stellar SDK integration (#470) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace placeholder soroban-sdk (contract-side) dependency with stellar-xdr for server-side XDR support; keep stellar-strkey for address encoding/decoding - Add stellar-xdr = { version = "21.0", features = ["curr", "alloc"] } to Cargo.toml (resolves the TODO comment) New module: backend/src/stellar.rs - StellarConfig: loads STELLAR_* env vars with sensible testnet defaults; documented in env.example - HorizonClient: async REST wrapper for the Stellar Horizon API (get_account, get_account_transactions, get_transaction, submit_transaction, health_check, get_latest_ledger) - SorobanRpcClient: JSON-RPC 2.0 client for the Soroban RPC endpoint (get_health, get_ledger_entries, simulate_transaction, send_transaction, get_transaction, get_latest_ledger, get_contract_data) - TransactionMonitor: polling-based monitor that tracks submitted transactions until SUCCESS / ERROR; exposes track/untrack/get_status and a start() method that spawns a background Tokio task - StellarClient: top-level facade bundling Horizon + Soroban RPC + TransactionMonitor with simulate_and_submit / submit_and_monitor convenience helpers and aggregate health_check Both HorizonClient and SorobanRpcClient use the existing CircuitBreaker and retry_async utilities consistent with the rest of the backend. 9 unit tests added covering config values, URL normalisation, monitor lifecycle (track/untrack), and status transitions (pending→success, pending→error, pending stays pending). env.example updated with all new STELLAR_* variables and documentation. Closes #470 --- backend/Cargo.lock | 694 +------------------------ backend/Cargo.toml | 9 +- backend/env.example | 27 + backend/src/lib.rs | 5 + backend/src/stellar.rs | 1125 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 1178 insertions(+), 682 deletions(-) create mode 100644 backend/src/stellar.rs diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 8c77532..731acd8 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -618,24 +618,12 @@ dependencies = [ "windows-link", ] -[[package]] -name = "base16ct" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" - [[package]] name = "base32" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23ce669cd6c8588f79e15cf450314f9638f967fc5770ff1c7c1deb0925ea7cfa" -[[package]] -name = "base64" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" - [[package]] name = "base64" version = "0.21.7" @@ -834,18 +822,6 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" -[[package]] -name = "bytes-lit" -version = "0.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0adabf37211a5276e46335feabcbb1530c95eb3fdf85f324c7db942770aa025d" -dependencies = [ - "num-bigint", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "bytestring" version = "1.5.0" @@ -1127,18 +1103,6 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" -[[package]] -name = "crypto-bigint" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" -dependencies = [ - "generic-array", - "rand_core 0.6.4", - "subtle", - "zeroize", -] - [[package]] name = "crypto-common" version = "0.1.7" @@ -1167,102 +1131,6 @@ dependencies = [ "cmov", ] -[[package]] -name = "curve25519-dalek" -version = "4.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "curve25519-dalek-derive", - "digest 0.10.7", - "fiat-crypto", - "rustc_version", - "subtle", - "zeroize", -] - -[[package]] -name = "curve25519-dalek-derive" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "darling" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" -dependencies = [ - "darling_core 0.20.11", - "darling_macro 0.20.11", -] - -[[package]] -name = "darling" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" -dependencies = [ - "darling_core 0.23.0", - "darling_macro 0.23.0", -] - -[[package]] -name = "darling_core" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.117", -] - -[[package]] -name = "darling_core" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" -dependencies = [ - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.117", -] - -[[package]] -name = "darling_macro" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" -dependencies = [ - "darling_core 0.20.11", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "darling_macro" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" -dependencies = [ - "darling_core 0.23.0", - "quote", - "syn 2.0.117", -] - [[package]] name = "dashmap" version = "5.5.3" @@ -1320,7 +1188,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", - "serde_core", ] [[package]] @@ -1427,62 +1294,12 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" -[[package]] -name = "downcast-rs" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" - [[package]] name = "dunce" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" -[[package]] -name = "dyn-clone" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" - -[[package]] -name = "ecdsa" -version = "0.16.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" -dependencies = [ - "der 0.7.10", - "digest 0.10.7", - "elliptic-curve", - "rfc6979", - "signature", -] - -[[package]] -name = "ed25519" -version = "2.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" -dependencies = [ - "pkcs8", - "signature", -] - -[[package]] -name = "ed25519-dalek" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" -dependencies = [ - "curve25519-dalek", - "ed25519", - "rand_core 0.6.4", - "serde", - "sha2 0.10.9", - "subtle", - "zeroize", -] - [[package]] name = "either" version = "1.15.0" @@ -1492,24 +1309,6 @@ dependencies = [ "serde", ] -[[package]] -name = "elliptic-curve" -version = "0.13.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" -dependencies = [ - "base16ct", - "crypto-bigint", - "digest 0.10.7", - "ff", - "generic-array", - "group", - "rand_core 0.6.4", - "sec1", - "subtle", - "zeroize", -] - [[package]] name = "ena" version = "0.14.4" @@ -1561,12 +1360,6 @@ dependencies = [ "windows-sys 0.48.0", ] -[[package]] -name = "ethnum" -version = "1.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40404c3f5f511ec4da6fe866ddf6a717c309fdbb69fbbad7b0f3edab8f2e835f" - [[package]] name = "event-listener" version = "2.5.3" @@ -1606,22 +1399,6 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" -[[package]] -name = "ff" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" -dependencies = [ - "rand_core 0.6.4", - "subtle", -] - -[[package]] -name = "fiat-crypto" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" - [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1835,7 +1612,6 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", - "zeroize", ] [[package]] @@ -1917,17 +1693,6 @@ dependencies = [ "spinning_top", ] -[[package]] -name = "group" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" -dependencies = [ - "ff", - "rand_core 0.6.4", - "subtle", -] - [[package]] name = "h2" version = "0.3.27" @@ -1940,7 +1705,7 @@ dependencies = [ "futures-sink", "futures-util", "http 0.2.12", - "indexmap 2.14.0", + "indexmap", "slab", "tokio", "tokio-util", @@ -1959,7 +1724,7 @@ dependencies = [ "futures-core", "futures-sink", "http 1.4.0", - "indexmap 2.14.0", + "indexmap", "slab", "tokio", "tokio-util", @@ -2063,15 +1828,6 @@ name = "hex" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" -dependencies = [ - "serde", -] - -[[package]] -name = "hex-literal" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46" [[package]] name = "hkdf" @@ -2450,12 +2206,6 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - [[package]] name = "idna" version = "1.1.0" @@ -2483,17 +2233,6 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2" -[[package]] -name = "indexmap" -version = "1.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" -dependencies = [ - "autocfg", - "hashbrown 0.12.3", - "serde", -] - [[package]] name = "indexmap" version = "2.14.0" @@ -2506,12 +2245,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "indexmap-nostd" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e04e2fd2b8188ea827b32ef11de88377086d690286ab35747ef7f9bf3ccb590" - [[package]] name = "inheritx-backend" version = "0.1.0" @@ -2544,9 +2277,9 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", - "soroban-sdk", "sqlx", "stellar-strkey 0.0.16", + "stellar-xdr", "thiserror 1.0.69", "tokio", "tokio-test", @@ -2700,27 +2433,6 @@ dependencies = [ "simple_asn1", ] -[[package]] -name = "k256" -version = "0.13.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" -dependencies = [ - "cfg-if", - "ecdsa", - "elliptic-curve", - "sha2 0.10.9", -] - -[[package]] -name = "keccak" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" -dependencies = [ - "cpufeatures 0.2.17", -] - [[package]] name = "kv-log-macro" version = "1.0.7" @@ -2932,7 +2644,7 @@ dependencies = [ "http-body-util", "hyper 1.9.0", "hyper-util", - "indexmap 2.14.0", + "indexmap", "ipnet", "metrics", "metrics-util", @@ -3112,17 +2824,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" -[[package]] -name = "num-derive" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "num-integer" version = "0.1.46" @@ -3397,18 +3098,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "p256" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" -dependencies = [ - "ecdsa", - "elliptic-curve", - "primeorder", - "sha2 0.10.9", -] - [[package]] name = "parking" version = "2.2.1" @@ -3534,7 +3223,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" dependencies = [ "fixedbitset", - "indexmap 2.14.0", + "indexmap", ] [[package]] @@ -3721,15 +3410,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "primeorder" -version = "0.13.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" -dependencies = [ - "elliptic-curve", -] - [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -4015,26 +3695,6 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "ref-cast" -version = "1.0.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" -dependencies = [ - "ref-cast-impl", -] - -[[package]] -name = "ref-cast-impl" -version = "1.0.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "regex" version = "1.12.3" @@ -4163,16 +3823,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "rfc6979" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" -dependencies = [ - "hmac 0.12.1", - "subtle", -] - [[package]] name = "ring" version = "0.17.14" @@ -4458,30 +4108,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "schemars" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] - -[[package]] -name = "schemars" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] - [[package]] name = "scopeguard" version = "1.2.0" @@ -4504,19 +4130,6 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" -[[package]] -name = "sec1" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" -dependencies = [ - "base16ct", - "der 0.7.10", - "generic-array", - "subtle", - "zeroize", -] - [[package]] name = "security-framework" version = "3.7.0" @@ -4771,37 +4384,6 @@ dependencies = [ "serde", ] -[[package]] -name = "serde_with" -version = "3.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" -dependencies = [ - "base64 0.22.1", - "chrono", - "hex", - "indexmap 1.9.3", - "indexmap 2.14.0", - "schemars 0.9.0", - "schemars 1.2.1", - "serde_core", - "serde_json", - "serde_with_macros", - "time", -] - -[[package]] -name = "serde_with_macros" -version = "3.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" -dependencies = [ - "darling 0.23.0", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "sha1" version = "0.10.6" @@ -4841,16 +4423,6 @@ dependencies = [ "digest 0.11.2", ] -[[package]] -name = "sha3" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" -dependencies = [ - "digest 0.10.7", - "keccak", -] - [[package]] name = "sharded-slab" version = "0.1.7" @@ -4954,186 +4526,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "soroban-builtin-sdk-macros" -version = "21.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f57a68ef8777e28e274de0f3a88ad9a5a41d9a2eb461b4dd800b086f0e83b80" -dependencies = [ - "itertools 0.11.0", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "soroban-env-common" -version = "21.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fd1c89463835fe6da996318156d39f424b4f167c725ec692e5a7a2d4e694b3d" -dependencies = [ - "crate-git-revision", - "ethnum", - "num-derive", - "num-traits", - "serde", - "soroban-env-macros", - "soroban-wasmi", - "static_assertions", - "stellar-xdr", - "wasmparser 0.116.1", -] - -[[package]] -name = "soroban-env-guest" -version = "21.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bfb2536811045d5cd0c656a324cbe9ce4467eb734c7946b74410d90dea5d0ce" -dependencies = [ - "soroban-env-common", - "static_assertions", -] - -[[package]] -name = "soroban-env-host" -version = "21.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b7a32c28f281c423189f1298960194f0e0fc4eeb72378028171e556d8cd6160" -dependencies = [ - "curve25519-dalek", - "ecdsa", - "ed25519-dalek", - "elliptic-curve", - "generic-array", - "getrandom 0.2.17", - "hex-literal", - "hmac 0.12.1", - "k256", - "num-derive", - "num-integer", - "num-traits", - "p256", - "rand 0.8.6", - "rand_chacha 0.3.1", - "sec1", - "sha2 0.10.9", - "sha3", - "soroban-builtin-sdk-macros", - "soroban-env-common", - "soroban-wasmi", - "static_assertions", - "stellar-strkey 0.0.8", - "wasmparser 0.116.1", -] - -[[package]] -name = "soroban-env-macros" -version = "21.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "242926fe5e0d922f12d3796cd7cd02dd824e5ef1caa088f45fce20b618309f64" -dependencies = [ - "itertools 0.11.0", - "proc-macro2", - "quote", - "serde", - "serde_json", - "stellar-xdr", - "syn 2.0.117", -] - -[[package]] -name = "soroban-ledger-snapshot" -version = "21.7.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6edf92749fd8399b417192d301c11f710b9cdce15789a3d157785ea971576fa" -dependencies = [ - "serde", - "serde_json", - "serde_with", - "soroban-env-common", - "soroban-env-host", - "thiserror 1.0.69", -] - -[[package]] -name = "soroban-sdk" -version = "21.7.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcdf04484af7cc731a7a48ad1d9f5f940370edeea84734434ceaf398a6b862e" -dependencies = [ - "bytes-lit", - "rand 0.8.6", - "rustc_version", - "serde", - "serde_json", - "soroban-env-guest", - "soroban-env-host", - "soroban-ledger-snapshot", - "soroban-sdk-macros", - "stellar-strkey 0.0.8", -] - -[[package]] -name = "soroban-sdk-macros" -version = "21.7.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0974e413731aeff2443f2305b344578b3f1ffd18335a7ba0f0b5d2eb4e94c9ce" -dependencies = [ - "crate-git-revision", - "darling 0.20.11", - "itertools 0.11.0", - "proc-macro2", - "quote", - "rustc_version", - "sha2 0.10.9", - "soroban-env-common", - "soroban-spec", - "soroban-spec-rust", - "stellar-xdr", - "syn 2.0.117", -] - -[[package]] -name = "soroban-spec" -version = "21.7.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2c70b20e68cae3ef700b8fa3ae29db1c6a294b311fba66918f90cb8f9fd0a1a" -dependencies = [ - "base64 0.13.1", - "stellar-xdr", - "thiserror 1.0.69", - "wasmparser 0.116.1", -] - -[[package]] -name = "soroban-spec-rust" -version = "21.7.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2dafbde981b141b191c6c036abc86097070ddd6eaaa33b273701449501e43d3" -dependencies = [ - "prettyplease", - "proc-macro2", - "quote", - "sha2 0.10.9", - "soroban-spec", - "stellar-xdr", - "syn 2.0.117", - "thiserror 1.0.69", -] - -[[package]] -name = "soroban-wasmi" -version = "0.31.1-soroban.20.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "710403de32d0e0c35375518cb995d4fc056d0d48966f2e56ea471b8cb8fc9719" -dependencies = [ - "smallvec", - "spin", - "wasmi_arena", - "wasmi_core", - "wasmparser-nostd", -] - [[package]] name = "spin" version = "0.9.8" @@ -5207,7 +4599,7 @@ dependencies = [ "futures-util", "hashlink", "hex", - "indexmap 2.14.0", + "indexmap", "log", "memchr", "once_cell", @@ -5386,12 +4778,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - [[package]] name = "stellar-strkey" version = "0.0.8" @@ -5420,12 +4806,9 @@ version = "21.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2675a71212ed39a806e415b0dbf4702879ff288ec7f5ee996dda42a135512b50" dependencies = [ - "base64 0.13.1", "crate-git-revision", "escape-bytes", "hex", - "serde", - "serde_with", "stellar-strkey 0.0.8", ] @@ -5452,12 +4835,6 @@ dependencies = [ "unicode-properties", ] -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - [[package]] name = "subtle" version = "2.6.1" @@ -5806,7 +5183,7 @@ version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap 2.14.0", + "indexmap", "serde", "serde_spanned", "toml_datetime 0.6.11", @@ -5820,7 +5197,7 @@ version = "0.25.11+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" dependencies = [ - "indexmap 2.14.0", + "indexmap", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "winnow 1.0.2", @@ -6300,7 +5677,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" dependencies = [ "leb128fmt", - "wasmparser 0.244.0", + "wasmparser", ] [[package]] @@ -6310,37 +5687,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap 2.14.0", + "indexmap", "wasm-encoder", - "wasmparser 0.244.0", -] - -[[package]] -name = "wasmi_arena" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "104a7f73be44570cac297b3035d76b169d6599637631cf37a1703326a0727073" - -[[package]] -name = "wasmi_core" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcf1a7db34bff95b85c261002720c00c3a6168256dcb93041d3fa2054d19856a" -dependencies = [ - "downcast-rs", - "libm", - "num-traits", - "paste", -] - -[[package]] -name = "wasmparser" -version = "0.116.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a58e28b80dd8340cb07b8242ae654756161f6fc8d0038123d679b7b99964fa50" -dependencies = [ - "indexmap 2.14.0", - "semver", + "wasmparser", ] [[package]] @@ -6351,19 +5700,10 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags 2.11.1", "hashbrown 0.15.5", - "indexmap 2.14.0", + "indexmap", "semver", ] -[[package]] -name = "wasmparser-nostd" -version = "0.100.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5a015fe95f3504a94bb1462c717aae75253e39b9dd6c3fb1062c934535c64aa" -dependencies = [ - "indexmap-nostd", -] - [[package]] name = "web-sys" version = "0.3.95" @@ -6858,7 +6198,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck 0.5.0", - "indexmap 2.14.0", + "indexmap", "prettyplease", "syn 2.0.117", "wasm-metadata", @@ -6889,14 +6229,14 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags 2.11.1", - "indexmap 2.14.0", + "indexmap", "log", "serde", "serde_derive", "serde_json", "wasm-encoder", "wasm-metadata", - "wasmparser 0.244.0", + "wasmparser", "wit-parser", ] @@ -6908,14 +6248,14 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap 2.14.0", + "indexmap", "log", "semver", "serde", "serde_derive", "serde_json", "unicode-xid", - "wasmparser 0.244.0", + "wasmparser", ] [[package]] diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 1c33385..fddabd4 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -39,11 +39,10 @@ ring = "0.17" sha2 = "0.10" postgres-types = { version = "0.2", features = ["with-chrono-0_4", "with-uuid-1", "with-serde_json-1"] } -# Stellar SDK - TODO: Use correct Stellar Rust SDK -# Check: https://crates.io/crates/stellar-rust-sdk or similar -# For now, we'll comment this out until we find the right crate -# stellar-sdk = "0.1" -soroban-sdk = "21.0" +# Stellar SDK integration (Issue #470) +# stellar-xdr: XDR types for transaction building/encoding (maintained by SDF) +stellar-xdr = { version = "21.0", features = ["curr", "alloc"] } +# stellar-strkey is already listed below; used for address encode/decode # HTTP client reqwest = { version = "0.11", default-features = false, features = ["json", "rustls-tls"] } diff --git a/backend/env.example b/backend/env.example index 6012c35..e653c14 100644 --- a/backend/env.example +++ b/backend/env.example @@ -15,6 +15,33 @@ INHERITX_STELLAR__NETWORK__PASSPHRASE=Test SDF Network ; September 2015 INHERITX_STELLAR__NETWORK__HORIZON_URL=https://horizon-testnet.stellar.org INHERITX_STELLAR__NETWORK__RPC_URL=https://soroban-testnet.stellar.org +# ── Stellar SDK Integration (Issue #470) ───────────────────────────────────── +# Network passphrase. Testnet: "Test SDF Network ; September 2015" +# Mainnet: "Public Global Stellar Network ; September 2015" +STELLAR_NETWORK_PASSPHRASE=Test SDF Network ; September 2015 + +# Horizon REST API base URL. +STELLAR_HORIZON_URL=https://horizon-testnet.stellar.org + +# Soroban JSON-RPC endpoint. +STELLAR_RPC_URL=https://soroban-testnet.stellar.org + +# HTTP timeout in seconds for Stellar API calls. +# Default: 30 +STELLAR_REQUEST_TIMEOUT_SECS=30 + +# Number of retry attempts for transient Stellar API errors. +# Default: 3 +STELLAR_MAX_RETRIES=3 + +# How often (seconds) the transaction monitor polls for status updates. +# Default: 5 +STELLAR_MONITOR_POLL_INTERVAL_SECS=5 + +# Maximum number of polling attempts before marking a transaction as timed out. +# Default: 60 (covers 5 min at 5-second intervals) +STELLAR_MONITOR_MAX_POLL_ATTEMPTS=60 + # Anchor Configuration INHERITX_ANCHOR__SEP24_URL=https://your-anchor.com/sep24 INHERITX_ANCHOR__SEP31_URL=https://your-anchor.com/sep31 diff --git a/backend/src/lib.rs b/backend/src/lib.rs index e53e699..68dec0c 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -46,6 +46,7 @@ pub mod secrets; pub mod secure_messages; pub mod service; pub mod session; +pub mod stellar; pub mod stress_testing; pub mod telemetry; pub mod webhook; @@ -77,5 +78,9 @@ pub use safe_math::SafeMath; pub use secure_messages::{ LegacyMessageDeliveryService, MessageEncryptionService, MessageKeyService, }; +pub use stellar::{ + HorizonClient, SorobanRpcClient, StellarClient, StellarConfig, StellarHealthStatus, + TransactionMonitor, TransactionStatus, +}; pub use stress_testing::StressTestingEngine; pub use webhook::{event_types, WebhookService}; diff --git a/backend/src/stellar.rs b/backend/src/stellar.rs new file mode 100644 index 0000000..c124444 --- /dev/null +++ b/backend/src/stellar.rs @@ -0,0 +1,1125 @@ +//! Stellar SDK integration (Issue #470). +//! +//! Provides: +//! * [`HorizonClient`] – REST wrapper around the Stellar Horizon API. +//! * [`SorobanRpcClient`] – JSON-RPC wrapper for the Soroban RPC endpoint, +//! enabling contract invocations from the backend. +//! * [`TransactionMonitor`] – polling-based transaction-status monitor. +//! +//! All network I/O goes through `reqwest`, which is already part of the +//! dependency graph. XDR encoding relies on `stellar-xdr`. + +use crate::api_error::ApiError; +use crate::circuit_breaker::CircuitBreaker; +use crate::retry::{retry_async, RetryConfig}; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::RwLock; +use tokio::time::sleep; +use tracing::{debug, error, info, warn}; + +// ───────────────────────────────────────────────────────────────────────────── +// Configuration +// ───────────────────────────────────────────────────────────────────────────── + +/// Stellar network configuration loaded from environment variables. +#[derive(Debug, Clone)] +pub struct StellarConfig { + /// Human-readable network passphrase used for transaction signing. + /// e.g. "Test SDF Network ; September 2015" for testnet. + pub network_passphrase: String, + /// Base URL for the Horizon REST API. + pub horizon_url: String, + /// Base URL for the Soroban JSON-RPC endpoint. + pub rpc_url: String, + /// HTTP request timeout in seconds. + pub request_timeout_secs: u64, + /// Maximum number of retry attempts for transient errors. + pub max_retries: u32, +} + +impl StellarConfig { + /// Load configuration from environment variables with sensible fallbacks. + /// + /// | Variable | Default | + /// |-----------------------------------|--------------------------------------------| + /// | `STELLAR_NETWORK_PASSPHRASE` | Test SDF Network ; September 2015 | + /// | `STELLAR_HORIZON_URL` | https://horizon-testnet.stellar.org | + /// | `STELLAR_RPC_URL` | https://soroban-testnet.stellar.org | + /// | `STELLAR_REQUEST_TIMEOUT_SECS` | 30 | + /// | `STELLAR_MAX_RETRIES` | 3 | + pub fn from_env() -> Self { + Self { + network_passphrase: std::env::var("STELLAR_NETWORK_PASSPHRASE") + .unwrap_or_else(|_| "Test SDF Network ; September 2015".to_string()), + horizon_url: std::env::var("STELLAR_HORIZON_URL") + .unwrap_or_else(|_| "https://horizon-testnet.stellar.org".to_string()), + rpc_url: std::env::var("STELLAR_RPC_URL") + .unwrap_or_else(|_| "https://soroban-testnet.stellar.org".to_string()), + request_timeout_secs: std::env::var("STELLAR_REQUEST_TIMEOUT_SECS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(30), + max_retries: std::env::var("STELLAR_MAX_RETRIES") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(3), + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Horizon API types +// ───────────────────────────────────────────────────────────────────────────── + +/// Summary of an account returned by the Horizon `/accounts/{id}` endpoint. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HorizonAccount { + pub id: String, + pub account_id: String, + pub sequence: String, + pub balances: Vec, + pub subentry_count: u32, + pub last_modified_ledger: u32, + pub last_modified_time: String, + pub thresholds: Thresholds, + pub flags: Flags, +} + +/// Asset balance for a Stellar account. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Balance { + pub balance: String, + pub limit: Option, + pub buying_liabilities: String, + pub selling_liabilities: String, + pub asset_type: String, + pub asset_code: Option, + pub asset_issuer: Option, +} + +/// Multi-sig threshold settings. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Thresholds { + pub low_threshold: u8, + pub med_threshold: u8, + pub high_threshold: u8, +} + +/// Account flags. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Flags { + pub auth_required: bool, + pub auth_revocable: bool, + pub auth_immutable: bool, + pub auth_clawback_enabled: bool, +} + +/// Represents a Stellar transaction record from Horizon. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HorizonTransaction { + pub id: String, + pub hash: String, + pub ledger: u32, + pub created_at: String, + pub source_account: String, + pub source_account_sequence: String, + pub fee_account: String, + pub fee_charged: String, + pub max_fee: String, + pub operation_count: u32, + pub envelope_xdr: String, + pub result_xdr: String, + pub successful: bool, + pub memo_type: String, + pub memo: Option, +} + +/// Page of Horizon records. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HorizonPage { + #[serde(rename = "_embedded")] + pub embedded: EmbeddedRecords, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EmbeddedRecords { + pub records: Vec, +} + +/// Response from Horizon when a transaction is submitted. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransactionSubmitResult { + pub hash: String, + pub ledger: Option, + pub envelope_xdr: String, + pub result_xdr: String, + pub result_meta_xdr: String, + pub successful: Option, +} + +// ───────────────────────────────────────────────────────────────────────────── +// Soroban RPC types +// ───────────────────────────────────────────────────────────────────────────── + +/// JSON-RPC 2.0 request wrapper. +#[derive(Debug, Serialize)] +struct JsonRpcRequest<'a> { + jsonrpc: &'static str, + id: u64, + method: &'a str, + params: Value, +} + +/// JSON-RPC 2.0 response wrapper. +#[derive(Debug, Deserialize)] +struct JsonRpcResponse { + #[allow(dead_code)] + pub id: Option, + pub result: Option, + pub error: Option, +} + +/// JSON-RPC error object. +#[derive(Debug, Deserialize)] +struct JsonRpcError { + pub code: i64, + pub message: String, +} + +/// Soroban RPC `getHealth` result. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SorobanHealth { + pub status: String, + pub latest_ledger: Option, + pub oldest_ledger: Option, +} + +/// Soroban RPC `getLedgerEntries` result. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LedgerEntry { + pub key: String, + pub xdr: String, + pub last_modified_ledger_seq: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetLedgerEntriesResult { + pub entries: Option>, + pub latest_ledger: u64, +} + +/// Soroban RPC `simulateTransaction` result. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SimulateTransactionResult { + pub error: Option, + pub results: Option>, + pub cost: Option, + pub latest_ledger: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SimulateInvocationResult { + pub auth: Vec, + pub xdr: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SimulateCost { + #[serde(rename = "cpuInsns")] + pub cpu_insns: String, + #[serde(rename = "memBytes")] + pub mem_bytes: String, +} + +/// Soroban RPC `sendTransaction` result. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SendTransactionResult { + pub status: String, + pub hash: String, + pub latest_ledger: u64, + #[serde(rename = "latestLedgerCloseTime")] + pub latest_ledger_close_time: String, + pub error_result_xdr: Option, +} + +/// Soroban RPC `getTransaction` result. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetTransactionResult { + pub status: String, + pub latest_ledger: u64, + #[serde(rename = "latestLedgerCloseTime")] + pub latest_ledger_close_time: String, + #[serde(rename = "oldestLedger")] + pub oldest_ledger: Option, + #[serde(rename = "oldestLedgerCloseTime")] + pub oldest_ledger_close_time: Option, + pub ledger: Option, + #[serde(rename = "createdAt")] + pub created_at: Option, + #[serde(rename = "applicationOrder")] + pub application_order: Option, + pub envelope_xdr: Option, + pub result_xdr: Option, + pub result_meta_xdr: Option, +} + +/// Known statuses for a pending Soroban transaction. +pub const SOROBAN_STATUS_PENDING: &str = "PENDING"; +pub const SOROBAN_STATUS_SUCCESS: &str = "SUCCESS"; +pub const SOROBAN_STATUS_ERROR: &str = "ERROR"; +pub const SOROBAN_STATUS_NOT_FOUND: &str = "NOT_FOUND"; + +// ───────────────────────────────────────────────────────────────────────────── +// HorizonClient +// ───────────────────────────────────────────────────────────────────────────── + +/// HTTP client for the Stellar Horizon REST API. +/// +/// Uses a circuit breaker to shed load when Horizon is unreachable, and +/// automatic retry with exponential back-off for transient failures. +#[derive(Clone)] +pub struct HorizonClient { + client: Client, + base_url: String, + circuit_breaker: CircuitBreaker, + config: StellarConfig, +} + +impl HorizonClient { + /// Construct a new `HorizonClient` from a [`StellarConfig`]. + pub fn new(config: StellarConfig) -> Self { + let client = Client::builder() + .timeout(Duration::from_secs(config.request_timeout_secs)) + .build() + .expect("failed to build reqwest client"); + + let circuit_breaker = CircuitBreaker::from_env("horizon", 5, 60); + + Self { + client, + base_url: config.horizon_url.trim_end_matches('/').to_string(), + circuit_breaker, + config, + } + } + + /// Build from environment variables via [`StellarConfig::from_env`]. + pub fn from_env() -> Self { + Self::new(StellarConfig::from_env()) + } + + // ── private helper ──────────────────────────────────────────────────────── + + async fn get Deserialize<'de>>(&self, path: &str) -> Result { + let url = format!("{}{}", self.base_url, path); + let max_retries = self.config.max_retries; + + self.circuit_breaker + .call(|| async { + retry_async( + RetryConfig { + max_attempts: max_retries, + ..RetryConfig::external_service() + }, + || { + let client = self.client.clone(); + let url = url.clone(); + async move { + debug!(url = %url, "Horizon GET request"); + let response = client + .get(&url) + .send() + .await + .map_err(|e| { + if e.is_timeout() { + ApiError::Timeout + } else { + ApiError::ExternalService(format!( + "Horizon request failed: {e}" + )) + } + })?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + warn!(status = %status, body = %body, "Horizon non-success response"); + return Err(ApiError::ExternalService(format!( + "Horizon returned status {status}: {body}" + ))); + } + + response.json::().await.map_err(|e| { + ApiError::ExternalService(format!( + "Failed to deserialize Horizon response: {e}" + )) + }) + } + }, + |e| matches!(e, ApiError::ExternalService(_) | ApiError::Timeout), + ) + .await + }) + .await + } + + // ── public API ──────────────────────────────────────────────────────────── + + /// Fetch account details for the given Stellar address. + pub async fn get_account(&self, account_id: &str) -> Result { + let path = format!("/accounts/{account_id}"); + info!(account_id, "Fetching Stellar account from Horizon"); + self.get(&path).await + } + + /// Fetch recent transactions for an account (newest first). + /// + /// `limit` is capped at 200 by Horizon. + pub async fn get_account_transactions( + &self, + account_id: &str, + limit: u32, + ) -> Result, ApiError> { + let limit = limit.min(200); + let path = format!( + "/accounts/{account_id}/transactions?limit={limit}&order=desc&include_failed=false" + ); + info!(account_id, limit, "Fetching account transactions from Horizon"); + let page: HorizonPage = self.get(&path).await?; + Ok(page.embedded.records) + } + + /// Fetch a single transaction by its hash. + pub async fn get_transaction(&self, tx_hash: &str) -> Result { + let path = format!("/transactions/{tx_hash}"); + debug!(tx_hash, "Fetching transaction from Horizon"); + self.get(&path).await + } + + /// Submit a base64-encoded XDR transaction envelope to the network. + pub async fn submit_transaction( + &self, + tx_xdr: &str, + ) -> Result { + let url = format!("{}/transactions", self.base_url); + info!("Submitting transaction to Horizon"); + + self.circuit_breaker + .call(|| async { + let response = self + .client + .post(&url) + .form(&[("tx", tx_xdr)]) + .send() + .await + .map_err(|e| { + if e.is_timeout() { + ApiError::Timeout + } else { + ApiError::ExternalService(format!( + "Horizon submit failed: {e}" + )) + } + })?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + error!(status = %status, body = %body, "Horizon rejected transaction"); + return Err(ApiError::ExternalService(format!( + "Horizon rejected transaction (status {status}): {body}" + ))); + } + + response + .json::() + .await + .map_err(|e| { + ApiError::ExternalService(format!( + "Failed to deserialize submit response: {e}" + )) + }) + }) + .await + } + + /// Check whether the Horizon server is reachable by fetching the root resource. + pub async fn health_check(&self) -> Result { + let url = format!("{}/", self.base_url); + let response = self + .client + .get(&url) + .send() + .await + .map_err(|e| ApiError::ExternalService(format!("Horizon health check failed: {e}")))?; + Ok(response.status().is_success()) + } + + /// Fetch the current ledger sequence number from Horizon. + pub async fn get_latest_ledger(&self) -> Result { + #[derive(Deserialize)] + struct Root { + core_latest_ledger: u32, + } + let root: Root = self.get("/").await?; + Ok(root.core_latest_ledger) + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// SorobanRpcClient +// ───────────────────────────────────────────────────────────────────────────── + +/// JSON-RPC 2.0 client for the Soroban smart-contract RPC endpoint. +/// +/// Wraps every call with the same circuit-breaker and retry patterns used +/// by the rest of the backend. +#[derive(Clone)] +pub struct SorobanRpcClient { + client: Client, + rpc_url: String, + circuit_breaker: CircuitBreaker, + request_id: Arc, + config: StellarConfig, +} + +impl SorobanRpcClient { + /// Construct a new client from the given config. + pub fn new(config: StellarConfig) -> Self { + let client = Client::builder() + .timeout(Duration::from_secs(config.request_timeout_secs)) + .build() + .expect("failed to build reqwest client"); + + let circuit_breaker = CircuitBreaker::from_env("soroban_rpc", 5, 60); + + Self { + client, + rpc_url: config.rpc_url.trim_end_matches('/').to_string(), + circuit_breaker, + request_id: Arc::new(std::sync::atomic::AtomicU64::new(1)), + config, + } + } + + /// Build from environment variables via [`StellarConfig::from_env`]. + pub fn from_env() -> Self { + Self::new(StellarConfig::from_env()) + } + + // ── private helper ──────────────────────────────────────────────────────── + + fn next_id(&self) -> u64 { + self.request_id + .fetch_add(1, std::sync::atomic::Ordering::Relaxed) + } + + async fn call Deserialize<'de>>( + &self, + method: &str, + params: Value, + ) -> Result { + let url = self.rpc_url.clone(); + let id = self.next_id(); + let max_retries = self.config.max_retries; + + self.circuit_breaker + .call(|| async { + retry_async( + RetryConfig { + max_attempts: max_retries, + ..RetryConfig::external_service() + }, + || { + let client = self.client.clone(); + let url = url.clone(); + let body = JsonRpcRequest { + jsonrpc: "2.0", + id, + method, + params: params.clone(), + }; + async move { + debug!(method, "Soroban RPC call"); + let response = client + .post(&url) + .json(&body) + .send() + .await + .map_err(|e| { + if e.is_timeout() { + ApiError::Timeout + } else { + ApiError::ExternalService(format!( + "Soroban RPC request failed: {e}" + )) + } + })?; + + if !response.status().is_success() { + let status = response.status(); + let text = response.text().await.unwrap_or_default(); + warn!(status = %status, body = %text, "Soroban RPC HTTP error"); + return Err(ApiError::ExternalService(format!( + "Soroban RPC HTTP {status}: {text}" + ))); + } + + let rpc_resp: JsonRpcResponse = + response.json().await.map_err(|e| { + ApiError::ExternalService(format!( + "Failed to parse Soroban RPC response: {e}" + )) + })?; + + if let Some(err) = rpc_resp.error { + return Err(ApiError::ExternalService(format!( + "Soroban RPC error {}: {}", + err.code, err.message + ))); + } + + rpc_resp.result.ok_or_else(|| { + ApiError::ExternalService( + "Soroban RPC returned empty result".to_string(), + ) + }) + } + }, + |e| matches!(e, ApiError::ExternalService(_) | ApiError::Timeout), + ) + .await + }) + .await + } + + // ── public API ──────────────────────────────────────────────────────────── + + /// Call `getHealth` – verifies the RPC server is live and reports the + /// latest ledger it has processed. + pub async fn get_health(&self) -> Result { + info!("Checking Soroban RPC health"); + self.call("getHealth", json!({})).await + } + + /// Call `getLedgerEntries` with the provided XDR-encoded keys. + pub async fn get_ledger_entries( + &self, + keys: Vec, + ) -> Result { + debug!(count = keys.len(), "Fetching ledger entries from Soroban RPC"); + self.call("getLedgerEntries", json!({ "keys": keys })).await + } + + /// Call `simulateTransaction` to estimate fees before submitting. + /// + /// `tx_xdr` is the base64-encoded XDR of the unsigned transaction envelope. + pub async fn simulate_transaction( + &self, + tx_xdr: &str, + ) -> Result { + info!("Simulating Soroban transaction"); + self.call( + "simulateTransaction", + json!({ "transaction": tx_xdr }), + ) + .await + } + + /// Call `sendTransaction` to broadcast a signed transaction to the network. + /// + /// Returns immediately with a status of `PENDING`; use + /// [`SorobanRpcClient::get_transaction`] or the [`TransactionMonitor`] to + /// await the final result. + pub async fn send_transaction(&self, tx_xdr: &str) -> Result { + info!("Sending transaction via Soroban RPC"); + self.call( + "sendTransaction", + json!({ "transaction": tx_xdr }), + ) + .await + } + + /// Call `getTransaction` to poll for the outcome of a previously submitted + /// transaction. + pub async fn get_transaction(&self, tx_hash: &str) -> Result { + debug!(tx_hash, "Polling Soroban RPC for transaction status"); + self.call("getTransaction", json!({ "hash": tx_hash })).await + } + + /// Call `getLatestLedger` to get the latest ledger known to the RPC server. + pub async fn get_latest_ledger(&self) -> Result { + debug!("Fetching latest ledger from Soroban RPC"); + self.call("getLatestLedger", json!({})).await + } + + /// Call `getContractData` to read a specific entry in a contract's storage. + /// + /// `contract_id` is the contract's Stellar address (C… form). + /// `key_xdr` is the base64-encoded XDR `ScVal` key. + pub async fn get_contract_data( + &self, + contract_id: &str, + key_xdr: &str, + durability: &str, + ) -> Result { + debug!(contract_id, "Fetching contract data from Soroban RPC"); + self.call( + "getContractData", + json!({ + "contract": contract_id, + "key": key_xdr, + "durability": durability + }), + ) + .await + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Transaction monitor +// ───────────────────────────────────────────────────────────────────────────── + +/// Status of a monitored transaction. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum TransactionStatus { + Pending, + Success, + Error, + NotFound, +} + +/// A single record kept by the [`TransactionMonitor`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MonitoredTransaction { + pub hash: String, + pub status: TransactionStatus, + pub submitted_at: chrono::DateTime, + pub updated_at: chrono::DateTime, + pub result_xdr: Option, + pub error_message: Option, + pub ledger: Option, +} + +/// Polls the Soroban RPC until all tracked transactions reach a terminal state. +/// +/// The monitor keeps an in-memory registry; submit a hash with +/// [`TransactionMonitor::track`] and retrieve the outcome with +/// [`TransactionMonitor::get_status`]. The background task started by +/// [`TransactionMonitor::start`] polls every `poll_interval` seconds. +#[derive(Clone)] +pub struct TransactionMonitor { + rpc: SorobanRpcClient, + registry: Arc>>, + poll_interval_secs: u64, + max_poll_attempts: u32, +} + +impl TransactionMonitor { + /// Create a new monitor backed by the given Soroban RPC client. + pub fn new(rpc: SorobanRpcClient) -> Self { + let poll_interval_secs: u64 = std::env::var("STELLAR_MONITOR_POLL_INTERVAL_SECS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(5); + let max_poll_attempts: u32 = std::env::var("STELLAR_MONITOR_MAX_POLL_ATTEMPTS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(60); + + Self { + rpc, + registry: Arc::new(RwLock::new(HashMap::new())), + poll_interval_secs, + max_poll_attempts, + } + } + + /// Register a transaction hash for monitoring. + pub async fn track(&self, hash: impl Into) { + let hash = hash.into(); + let now = chrono::Utc::now(); + let entry = MonitoredTransaction { + hash: hash.clone(), + status: TransactionStatus::Pending, + submitted_at: now, + updated_at: now, + result_xdr: None, + error_message: None, + ledger: None, + }; + info!(hash, "Tracking Soroban transaction"); + self.registry.write().await.insert(hash, entry); + } + + /// Return the current status record for `hash`, if tracked. + pub async fn get_status(&self, hash: &str) -> Option { + self.registry.read().await.get(hash).cloned() + } + + /// Remove a completed/errored transaction from the registry. + pub async fn untrack(&self, hash: &str) { + self.registry.write().await.remove(hash); + } + + /// Spawn a Tokio task that periodically polls all pending transactions. + /// + /// The returned handle is a `JoinHandle`; dropping it does **not** cancel + /// the task. Call [`tokio::task::JoinHandle::abort`] if you need to stop + /// monitoring. + pub fn start(self) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + info!( + poll_interval_secs = self.poll_interval_secs, + "Stellar transaction monitor started" + ); + loop { + sleep(Duration::from_secs(self.poll_interval_secs)).await; + self.poll_once().await; + } + }) + } + + /// Run one polling cycle over all pending hashes. Exported for testing. + pub async fn poll_once(&self) { + let pending: Vec = { + let guard = self.registry.read().await; + guard + .values() + .filter(|t| t.status == TransactionStatus::Pending) + .map(|t| t.hash.clone()) + .collect() + }; + + for hash in pending { + match self.rpc.get_transaction(&hash).await { + Ok(result) => self.apply_result(&hash, result).await, + Err(e) => { + warn!(hash, error = %e, "Failed to poll transaction status"); + // Keep the entry as Pending; will retry next cycle. + let mut guard = self.registry.write().await; + if let Some(entry) = guard.get_mut(&hash) { + entry.updated_at = chrono::Utc::now(); + // If we have exceeded max_poll_attempts mark as error. + let age_secs = (chrono::Utc::now() - entry.submitted_at).num_seconds(); + let max_age = + (self.max_poll_attempts as i64) * (self.poll_interval_secs as i64); + if age_secs > max_age { + error!(hash, age_secs, "Transaction monitoring timed out"); + entry.status = TransactionStatus::Error; + entry.error_message = + Some("Monitoring timed out – transaction not confirmed".to_string()); + } + } + } + } + } + } + + async fn apply_result(&self, hash: &str, result: GetTransactionResult) { + let mut guard = self.registry.write().await; + let Some(entry) = guard.get_mut(hash) else { + return; + }; + + entry.updated_at = chrono::Utc::now(); + match result.status.as_str() { + SOROBAN_STATUS_SUCCESS => { + info!(hash, "Soroban transaction confirmed successfully"); + entry.status = TransactionStatus::Success; + entry.result_xdr = result.result_xdr; + entry.ledger = result.ledger; + } + SOROBAN_STATUS_ERROR => { + error!(hash, "Soroban transaction failed on-chain"); + entry.status = TransactionStatus::Error; + entry.result_xdr = result.result_xdr; + entry.error_message = Some("Transaction failed on-chain".to_string()); + entry.ledger = result.ledger; + } + SOROBAN_STATUS_NOT_FOUND => { + // Transaction may not yet be ingested; keep as Pending. + debug!(hash, "Transaction not yet found in Soroban RPC"); + } + SOROBAN_STATUS_PENDING => { + debug!(hash, "Transaction still pending"); + } + other => { + warn!(hash, status = other, "Unknown transaction status from Soroban RPC"); + } + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// StellarClient – top-level façade +// ───────────────────────────────────────────────────────────────────────────── + +/// Convenience wrapper that bundles the Horizon client, Soroban RPC client, +/// and transaction monitor into a single `Arc`-cloneable handle. +#[derive(Clone)] +pub struct StellarClient { + pub horizon: HorizonClient, + pub soroban: SorobanRpcClient, + pub monitor: TransactionMonitor, + pub config: StellarConfig, +} + +impl StellarClient { + /// Build a full Stellar client from environment variables. + pub fn from_env() -> Self { + let config = StellarConfig::from_env(); + let horizon = HorizonClient::new(config.clone()); + let soroban = SorobanRpcClient::new(config.clone()); + let monitor = TransactionMonitor::new(soroban.clone()); + Self { + horizon, + soroban, + monitor, + config, + } + } + + /// Submit a signed transaction XDR via Soroban RPC, automatically + /// registering it with the [`TransactionMonitor`]. + /// + /// Returns the initial [`SendTransactionResult`] (status will be `PENDING`). + pub async fn submit_and_monitor( + &self, + tx_xdr: &str, + ) -> Result { + let result = self.soroban.send_transaction(tx_xdr).await?; + self.monitor.track(result.hash.clone()).await; + Ok(result) + } + + /// Convenience: simulate, then send, then monitor a transaction. + /// + /// Returns an error if simulation reports a failure; otherwise sends + /// the transaction and begins monitoring. + pub async fn simulate_and_submit( + &self, + tx_xdr: &str, + ) -> Result { + let sim = self.soroban.simulate_transaction(tx_xdr).await?; + if let Some(err) = sim.error { + return Err(ApiError::ExternalService(format!( + "Transaction simulation failed: {err}" + ))); + } + self.submit_and_monitor(tx_xdr).await + } + + /// Health check for both Horizon and Soroban RPC. + pub async fn health_check(&self) -> StellarHealthStatus { + let horizon_ok = self.horizon.health_check().await.unwrap_or(false); + let soroban_ok = self + .soroban + .get_health() + .await + .map(|h| h.status == "healthy") + .unwrap_or(false); + + StellarHealthStatus { + horizon_reachable: horizon_ok, + soroban_reachable: soroban_ok, + network_passphrase: self.config.network_passphrase.clone(), + } + } +} + +/// Aggregate health status for the Stellar integration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StellarHealthStatus { + pub horizon_reachable: bool, + pub soroban_reachable: bool, + pub network_passphrase: String, +} + +// ───────────────────────────────────────────────────────────────────────────── +// Unit tests +// ───────────────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + fn testnet_config() -> StellarConfig { + StellarConfig { + network_passphrase: "Test SDF Network ; September 2015".to_string(), + horizon_url: "https://horizon-testnet.stellar.org".to_string(), + rpc_url: "https://soroban-testnet.stellar.org".to_string(), + request_timeout_secs: 30, + max_retries: 3, + } + } + + fn mainnet_config() -> StellarConfig { + StellarConfig { + network_passphrase: "Public Global Stellar Network ; September 2015".to_string(), + horizon_url: "https://horizon.stellar.org".to_string(), + rpc_url: "https://rpc.stellar.org".to_string(), + request_timeout_secs: 30, + max_retries: 3, + } + } + + #[test] + fn stellar_config_testnet_passphrase() { + let cfg = testnet_config(); + assert_eq!(cfg.network_passphrase, "Test SDF Network ; September 2015"); + assert!(cfg.horizon_url.contains("horizon-testnet.stellar.org")); + assert!(cfg.rpc_url.contains("soroban-testnet.stellar.org")); + } + + #[test] + fn stellar_config_mainnet_passphrase() { + let cfg = mainnet_config(); + assert_eq!( + cfg.network_passphrase, + "Public Global Stellar Network ; September 2015" + ); + assert_eq!(cfg.horizon_url, "https://horizon.stellar.org"); + assert_eq!(cfg.rpc_url, "https://rpc.stellar.org"); + } + + #[test] + fn horizon_client_strips_trailing_slash() { + let mut cfg = testnet_config(); + cfg.horizon_url = "https://horizon-testnet.stellar.org/".to_string(); + let client = HorizonClient::new(cfg); + assert!(!client.base_url.ends_with('/')); + } + + #[test] + fn soroban_rpc_client_strips_trailing_slash() { + let mut cfg = testnet_config(); + cfg.rpc_url = "https://soroban-testnet.stellar.org/".to_string(); + let client = SorobanRpcClient::new(cfg); + assert!(!client.rpc_url.ends_with('/')); + } + + #[tokio::test] + async fn transaction_monitor_track_and_get() { + let soroban = SorobanRpcClient::new(testnet_config()); + let monitor = TransactionMonitor::new(soroban); + + let hash = "abc123def456"; + monitor.track(hash).await; + + let status = monitor.get_status(hash).await; + assert!(status.is_some()); + let entry = status.unwrap(); + assert_eq!(entry.hash, hash); + assert_eq!(entry.status, TransactionStatus::Pending); + + monitor.untrack(hash).await; + assert!(monitor.get_status(hash).await.is_none()); + } + + #[tokio::test] + async fn monitor_apply_success_result() { + let soroban = SorobanRpcClient::new(testnet_config()); + let monitor = TransactionMonitor::new(soroban); + + let hash = "success_hash_001"; + monitor.track(hash).await; + + let result = GetTransactionResult { + status: SOROBAN_STATUS_SUCCESS.to_string(), + latest_ledger: 12345, + latest_ledger_close_time: "2024-01-01T00:00:00Z".to_string(), + oldest_ledger: None, + oldest_ledger_close_time: None, + ledger: Some(12345), + created_at: None, + application_order: None, + envelope_xdr: None, + result_xdr: Some("result_xdr_here".to_string()), + result_meta_xdr: None, + }; + + monitor.apply_result(hash, result).await; + let entry = monitor.get_status(hash).await.unwrap(); + assert_eq!(entry.status, TransactionStatus::Success); + assert_eq!(entry.ledger, Some(12345)); + } + + #[tokio::test] + async fn monitor_apply_error_result() { + let soroban = SorobanRpcClient::new(testnet_config()); + let monitor = TransactionMonitor::new(soroban); + + let hash = "error_hash_002"; + monitor.track(hash).await; + + let result = GetTransactionResult { + status: SOROBAN_STATUS_ERROR.to_string(), + latest_ledger: 12345, + latest_ledger_close_time: "2024-01-01T00:00:00Z".to_string(), + oldest_ledger: None, + oldest_ledger_close_time: None, + ledger: Some(12345), + created_at: None, + application_order: None, + envelope_xdr: None, + result_xdr: None, + result_meta_xdr: None, + }; + + monitor.apply_result(hash, result).await; + let entry = monitor.get_status(hash).await.unwrap(); + assert_eq!(entry.status, TransactionStatus::Error); + assert!(entry.error_message.is_some()); + } + + #[tokio::test] + async fn monitor_pending_stays_pending() { + let soroban = SorobanRpcClient::new(testnet_config()); + let monitor = TransactionMonitor::new(soroban); + + let hash = "pending_hash_003"; + monitor.track(hash).await; + + let result = GetTransactionResult { + status: SOROBAN_STATUS_PENDING.to_string(), + latest_ledger: 12345, + latest_ledger_close_time: "2024-01-01T00:00:00Z".to_string(), + oldest_ledger: None, + oldest_ledger_close_time: None, + ledger: None, + created_at: None, + application_order: None, + envelope_xdr: None, + result_xdr: None, + result_meta_xdr: None, + }; + + monitor.apply_result(hash, result).await; + let entry = monitor.get_status(hash).await.unwrap(); + // Still pending – should not be promoted to error or success + assert_eq!(entry.status, TransactionStatus::Pending); + } + + #[tokio::test] + async fn monitor_multiple_transactions() { + let soroban = SorobanRpcClient::new(testnet_config()); + let monitor = TransactionMonitor::new(soroban); + + for i in 0..5 { + monitor.track(format!("hash_{i}")).await; + } + + for i in 0..5 { + let status = monitor.get_status(&format!("hash_{i}")).await; + assert!(status.is_some()); + assert_eq!(status.unwrap().status, TransactionStatus::Pending); + } + } +}