From 84ecdb612ebf3ad7d43503dcdf88b9399fdb0208 Mon Sep 17 00:00:00 2001 From: Gorka Date: Wed, 12 Aug 2026 11:46:57 -0300 Subject: [PATCH 1/2] crates/api: Serve /provider/treasury from Horizon and delete the six dead operator stubs. An empty-shape 200 reads as a truthful "nothing to report" on the console, so every operator route now answers from a real source or stops existing. Horizon is configured via an explicit required STELLAR_HORIZON_URL rather than derived from STELLAR_RPC_URL, which only shares a host on the local quickstart. --- .env.example | 11 +- crates/api/src/error.rs | 8 ++ crates/api/src/routes/dashboard_pp.rs | 2 +- crates/api/src/routes/operator.rs | 141 +++++++++++++++----------- crates/api/src/routing.rs | 6 -- crates/api/tests/common/mod.rs | 1 + crates/api/tests/executor_tick.rs | 1 + crates/api/tests/mempool_tick.rs | 1 + crates/api/tests/sep10_http.rs | 1 + crates/api/tests/verifier_tick.rs | 1 + crates/api/tests/ws_events_e2e.rs | 1 + crates/core/src/config.rs | 12 +++ 12 files changed, 120 insertions(+), 66 deletions(-) diff --git a/.env.example b/.env.example index 2d751e5..0dca2da 100644 --- a/.env.example +++ b/.env.example @@ -28,7 +28,7 @@ POSTGRES_DB=provider_stack DATABASE_URL=postgres://provider:provider@db:5432/provider_stack # ============================================================================ -# Stellar network + RPC (RPC is EXTERNAL — not a container) +# Stellar network + RPC + Horizon (both are EXTERNAL — not containers) # ============================================================================ # REQUIRED — testnet | mainnet | local NETWORK=testnet @@ -37,6 +37,15 @@ NETWORK_FEE=100 # Point at a real RPC for your network. If unset, the app falls back to a # per-network default (testnet -> https://soroban-testnet.stellar.org). STELLAR_RPC_URL=https://soroban-testnet.stellar.org +# REQUIRED — Horizon base URL, no trailing path. Used by GET /provider/treasury +# to read the PP account (`/accounts/`: sequence, balances, last ledger). +# Set it explicitly: Horizon and Soroban RPC are the same host ONLY on the local +# quickstart (:8000 serves both). On testnet/mainnet they are separate +# deployments, so anything derived from STELLAR_RPC_URL points at the wrong one. +# mainnet -> https://horizon.stellar.org +# testnet -> https://horizon-testnet.stellar.org +# local -> http://localhost:8000 +STELLAR_HORIZON_URL=https://horizon-testnet.stellar.org # optional (default 1000) TRANSACTION_EXPIRATION_OFFSET=1000 diff --git a/crates/api/src/error.rs b/crates/api/src/error.rs index c47b7a5..c2f2d9e 100644 --- a/crates/api/src/error.rs +++ b/crates/api/src/error.rs @@ -19,6 +19,12 @@ pub enum ApiError { #[error("not implemented")] NotImplemented, + /// An upstream network dependency (Horizon / RPC) is unreachable or + /// answered with an error. 503, not 500: nothing is wrong with this + /// service, and the client may retry. + #[error("service unavailable: {0}")] + ServiceUnavailable(String), + #[error("internal: {0}")] Internal(String), } @@ -37,6 +43,7 @@ impl ResponseError for ApiError { Self::Forbidden => StatusCode::FORBIDDEN, Self::NotFound => StatusCode::NOT_FOUND, Self::NotImplemented => StatusCode::NOT_IMPLEMENTED, + Self::ServiceUnavailable(_) => StatusCode::SERVICE_UNAVAILABLE, Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, } } @@ -48,6 +55,7 @@ impl ResponseError for ApiError { Self::Forbidden => "forbidden", Self::NotFound => "not_found", Self::NotImplemented => "not_implemented", + Self::ServiceUnavailable(_) => "service_unavailable", Self::Internal(_) => "internal", }; HttpResponse::build(self.status_code()).json(ErrorBody { diff --git a/crates/api/src/routes/dashboard_pp.rs b/crates/api/src/routes/dashboard_pp.rs index bfafd91..553b1d2 100644 --- a/crates/api/src/routes/dashboard_pp.rs +++ b/crates/api/src/routes/dashboard_pp.rs @@ -37,7 +37,7 @@ pub struct PpRecord { pub council_memberships: Vec, } -fn pp_public_strkey_from_env(state: &AppState) -> Result { +pub(crate) fn pp_public_strkey_from_env(state: &AppState) -> Result { let signing = provider_stack_core::auth::sep10::signing_key_from_seed(&state.config.pp_secret_key)?; Ok(format!( diff --git a/crates/api/src/routes/operator.rs b/crates/api/src/routes/operator.rs index cf8c9d4..97df5df 100644 --- a/crates/api/src/routes/operator.rs +++ b/crates/api/src/routes/operator.rs @@ -1,10 +1,17 @@ //! Operator analytics endpoints. Single-PP: the PP is env-pinned, so the //! URLs carry no `:pp` segment — the routes are flat under `/provider/`. //! -//! **Status**: scaffold — each returns an empty-shape JSON wrapped in the -//! `{ data: ... }` envelope the SPA reads, with the field names the SPA -//! consumers expect (metrics → `snapshots`, bundles list → `bundles`, -//! treasury → `address`/`balances`/…). Replace with real implementations. +//! Every route here answers from a real source — the repos in +//! `provider-stack-persistence` or, for `/provider/treasury`, Horizon. There is +//! deliberately no empty-shape placeholder left: an endpoint with nothing +//! behind it is deleted, not stubbed, because a 200 carrying `[]` is read by +//! the console as a truthful "nothing to report". +//! +//! Removed in the same pass (registered nowhere, consumed by nothing, and no +//! repo query behind them): `/provider/channels`, `/provider/mempool` (queue +//! depth is already served by `/provider/metrics`), `/provider/utxos`, +//! `/provider/transactions`, `/provider/transactions/{id}` and +//! `/provider/audit-export`. use crate::envelope::Data; use crate::error::ApiError; @@ -19,53 +26,80 @@ use provider_stack_persistence::{ use serde::{Deserialize, Serialize}; use serde_json::Value as JsonValue; -macro_rules! stub_get { - ($fn:ident, $path:literal, $body:expr) => { - #[get($path)] - pub async fn $fn( - _state: web::Data, - _auth: OperatorAuth, - ) -> Result { - Ok::<_, ApiError>(HttpResponse::Ok().json(Data::new($body))) - } - }; +// ----------------------------------------------------------------------------- +// GET /provider/treasury — the PP's own Stellar account, read from Horizon. +// +// Consumed by the SPA's OpEx card (`frontend/src/lib/api.ts::getTreasury` → +// `views/provider.ts`, which picks the `asset_type === "native"` balance). +// +// Horizon, not Soroban RPC: `balances` is the full multi-asset set (native plus +// every trustline), and the account's trustlines cannot be enumerated over +// `getLedgerEntries` — that call answers per exact ledger key, so an RPC-backed +// version could only ever report the native balance and would silently drop a +// USDC (or any issued-asset) treasury position. `balances` entries are relayed +// verbatim so the wire shape stays Horizon's (`asset_type` / `asset_code` / +// `balance`, which is what the SPA type declares). +// ----------------------------------------------------------------------------- + +#[derive(Deserialize)] +struct HorizonAccount { + sequence: String, + balances: Vec, + last_modified_ledger: u32, } -stub_get!( - get_channels, - "/provider/channels", - serde_json::json!({ "channels": [] }) -); -stub_get!( - get_mempool, - "/provider/mempool", - serde_json::json!({ "slots": [] }) -); -stub_get!( - get_treasury, - "/provider/treasury", - serde_json::json!({ - "address": "", - "sequence": "0", - "balances": [], - "lastModifiedLedger": 0 - }) -); -stub_get!( - get_utxos, - "/provider/utxos", - serde_json::json!({ "utxos": [] }) -); -stub_get!( - get_transactions, - "/provider/transactions", - serde_json::json!({ "transactions": [] }) -); -stub_get!( - get_audit_export, - "/provider/audit-export", - serde_json::json!({ "entries": [] }) -); +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TreasuryPayload { + pub address: String, + pub sequence: String, + pub balances: Vec, + pub last_modified_ledger: u32, +} + +#[get("/provider/treasury")] +pub async fn get_treasury( + state: web::Data, + _auth: OperatorAuth, +) -> Result { + let address = crate::routes::dashboard_pp::pp_public_strkey_from_env(&state)?; + let url = format!("{}/accounts/{}", state.config.stellar_horizon_url, address); + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .map_err(|e| ApiError::Internal(format!("http client: {e}")))?; + + let resp = client + .get(&url) + .send() + .await + .map_err(|e| ApiError::ServiceUnavailable(format!("horizon unreachable: {e}")))?; + + // 404 means the account is not funded yet — a real, reportable state, and + // not something the operator should see as a server error. + if resp.status() == reqwest::StatusCode::NOT_FOUND { + return Err(ApiError::NotFound); + } + if !resp.status().is_success() { + return Err(ApiError::ServiceUnavailable(format!( + "horizon returned {}", + resp.status() + ))); + } + + let account: HorizonAccount = resp + .json() + .await + .map_err(|e| ApiError::ServiceUnavailable(format!("horizon response: {e}")))?; + + Ok(HttpResponse::Ok().json(Data::new(TreasuryPayload { + address, + sequence: account.sequence, + balances: account.balances, + last_modified_ledger: account.last_modified_ledger, + }))) +} #[derive(Deserialize)] pub struct MetricsQuery { @@ -324,12 +358,3 @@ pub async fn get_bundle( amount, }))) } - -#[get("/provider/transactions/{tx_id}")] -pub async fn get_transaction( - _state: web::Data, - _auth: OperatorAuth, - _path: web::Path, -) -> Result { - Err::(ApiError::NotFound) -} diff --git a/crates/api/src/routing.rs b/crates/api/src/routing.rs index d058041..97d8138 100644 --- a/crates/api/src/routing.rs +++ b/crates/api/src/routing.rs @@ -27,15 +27,9 @@ pub fn configure(cfg: &mut web::ServiceConfig) { .service(routes::bundles::entity_status) .service(routes::bundles::list_entity) .service(routes::bundles::get_entity_bundle) - .service(routes::operator::get_channels) - .service(routes::operator::get_mempool) .service(routes::operator::get_treasury) - .service(routes::operator::get_utxos) - .service(routes::operator::get_transactions) - .service(routes::operator::get_transaction) .service(routes::operator::get_bundles) .service(routes::operator::get_bundle) - .service(routes::operator::get_audit_export) .service(routes::operator::get_metrics) .service(routes::events::ws_events), ) diff --git a/crates/api/tests/common/mod.rs b/crates/api/tests/common/mod.rs index efe4d92..3e0c991 100644 --- a/crates/api/tests/common/mod.rs +++ b/crates/api/tests/common/mod.rs @@ -97,6 +97,7 @@ pub fn build_test_app_state( network: "standalone".into(), network_fee: 1_000_000, stellar_rpc_url: String::new(), + stellar_horizon_url: String::new(), transaction_expiration_offset: 1_000, event_watcher_interval: Duration::from_millis(30_000), service_domain: service_domain.into(), diff --git a/crates/api/tests/executor_tick.rs b/crates/api/tests/executor_tick.rs index ad43e75..dddb9a9 100644 --- a/crates/api/tests/executor_tick.rs +++ b/crates/api/tests/executor_tick.rs @@ -151,6 +151,7 @@ fn cfg(rpc_url: &str) -> Arc { network: "standalone".into(), network_fee: 1_000_000, stellar_rpc_url: rpc_url.into(), + stellar_horizon_url: String::new(), transaction_expiration_offset: 1_000, event_watcher_interval: StdDuration::from_millis(30_000), service_domain: "smoke.local".into(), diff --git a/crates/api/tests/mempool_tick.rs b/crates/api/tests/mempool_tick.rs index 7c74021..6cb0eaf 100644 --- a/crates/api/tests/mempool_tick.rs +++ b/crates/api/tests/mempool_tick.rs @@ -33,6 +33,7 @@ fn cfg_with_capacity(capacity: usize) -> Arc { network: "standalone".into(), network_fee: 1_000_000, stellar_rpc_url: String::new(), + stellar_horizon_url: String::new(), transaction_expiration_offset: 1_000, event_watcher_interval: StdDuration::from_millis(30_000), service_domain: "smoke.local".into(), diff --git a/crates/api/tests/sep10_http.rs b/crates/api/tests/sep10_http.rs index 1bf9602..d60596f 100644 --- a/crates/api/tests/sep10_http.rs +++ b/crates/api/tests/sep10_http.rs @@ -50,6 +50,7 @@ fn make_test_state(pp_seed: [u8; 32], operator_pubkey_strkey: String) -> AppStat network: "standalone".into(), network_fee: 1_000_000, stellar_rpc_url: String::new(), + stellar_horizon_url: String::new(), transaction_expiration_offset: 1_000, event_watcher_interval: Duration::from_millis(30_000), service_domain: "smoke.local".into(), diff --git a/crates/api/tests/verifier_tick.rs b/crates/api/tests/verifier_tick.rs index ea38453..63b33c0 100644 --- a/crates/api/tests/verifier_tick.rs +++ b/crates/api/tests/verifier_tick.rs @@ -42,6 +42,7 @@ fn cfg() -> Arc { network: "standalone".into(), network_fee: 1_000_000, stellar_rpc_url: String::new(), + stellar_horizon_url: String::new(), transaction_expiration_offset: 1_000, event_watcher_interval: StdDuration::from_millis(30_000), service_domain: "smoke.local".into(), diff --git a/crates/api/tests/ws_events_e2e.rs b/crates/api/tests/ws_events_e2e.rs index 8bb3554..00d0798 100644 --- a/crates/api/tests/ws_events_e2e.rs +++ b/crates/api/tests/ws_events_e2e.rs @@ -56,6 +56,7 @@ fn make_state(events: EventBroadcaster) -> AppState { network: "standalone".into(), network_fee: 1_000_000, stellar_rpc_url: String::new(), + stellar_horizon_url: String::new(), transaction_expiration_offset: 1_000, event_watcher_interval: Duration::from_millis(30_000), service_domain: "smoke.local".into(), diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 84ab136..9ae5678 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -13,6 +13,15 @@ pub struct Config { pub network: String, pub network_fee: i64, pub stellar_rpc_url: String, + /// Horizon base URL (no trailing path), e.g. `https://horizon-testnet.stellar.org`. + /// + /// REQUIRED and explicit. It is deliberately NOT derived from + /// `stellar_rpc_url`: Horizon and Soroban RPC only share a host on the + /// local quickstart (`:8000` serves both). Deriving one from the other by + /// string surgery works locally and silently points at the wrong service on + /// testnet/mainnet, where they are separate deployments + /// (`soroban-testnet.stellar.org` vs `horizon-testnet.stellar.org`). + pub stellar_horizon_url: String, pub transaction_expiration_offset: u32, pub event_watcher_interval: Duration, pub service_domain: String, @@ -52,6 +61,9 @@ impl Config { default_rpc_for_network(env::var("NETWORK").unwrap_or_default().as_str()) .to_string() }), + stellar_horizon_url: required("STELLAR_HORIZON_URL")? + .trim_end_matches('/') + .to_string(), transaction_expiration_offset: env_or("TRANSACTION_EXPIRATION_OFFSET", "1000")? .parse()?, event_watcher_interval: Duration::from_millis( From 0682e794dcfecf40cdf81a6661c12a9c8eff9670 Mon Sep 17 00:00:00 2001 From: Gorka Date: Wed, 12 Aug 2026 13:32:05 -0300 Subject: [PATCH 2/2] crates/api: Align treasury error semantics with the provider-platform reference. The reference (provider-platform dashboard/treasury.ts) treats the Horizon URL as optional (per-request 503 when unset) and maps every Horizon failure, including 404 for an unfunded account, to a blanket 503. The standin now does the same instead of requiring the var at boot and answering 404. --- .env.example | 7 ++++--- crates/api/src/routes/operator.rs | 16 ++++++++++------ crates/api/tests/common/mod.rs | 2 +- crates/api/tests/executor_tick.rs | 2 +- crates/api/tests/mempool_tick.rs | 2 +- crates/api/tests/sep10_http.rs | 2 +- crates/api/tests/verifier_tick.rs | 2 +- crates/api/tests/ws_events_e2e.rs | 2 +- crates/core/src/config.rs | 23 +++++++++++++---------- 9 files changed, 33 insertions(+), 25 deletions(-) diff --git a/.env.example b/.env.example index 0dca2da..faf8522 100644 --- a/.env.example +++ b/.env.example @@ -37,9 +37,10 @@ NETWORK_FEE=100 # Point at a real RPC for your network. If unset, the app falls back to a # per-network default (testnet -> https://soroban-testnet.stellar.org). STELLAR_RPC_URL=https://soroban-testnet.stellar.org -# REQUIRED — Horizon base URL, no trailing path. Used by GET /provider/treasury -# to read the PP account (`/accounts/`: sequence, balances, last ledger). -# Set it explicitly: Horizon and Soroban RPC are the same host ONLY on the local +# optional — Horizon base URL, no trailing path. Used by GET /provider/treasury +# to read the PP account (`/accounts/`: sequence, balances, last ledger); +# when unset that endpoint answers 503 and everything else works. Set it +# explicitly: Horizon and Soroban RPC are the same host ONLY on the local # quickstart (:8000 serves both). On testnet/mainnet they are separate # deployments, so anything derived from STELLAR_RPC_URL points at the wrong one. # mainnet -> https://horizon.stellar.org diff --git a/crates/api/src/routes/operator.rs b/crates/api/src/routes/operator.rs index 97df5df..d9371fe 100644 --- a/crates/api/src/routes/operator.rs +++ b/crates/api/src/routes/operator.rs @@ -63,7 +63,14 @@ pub async fn get_treasury( _auth: OperatorAuth, ) -> Result { let address = crate::routes::dashboard_pp::pp_public_strkey_from_env(&state)?; - let url = format!("{}/accounts/{}", state.config.stellar_horizon_url, address); + // Mirrors the reference (`provider-platform/src/http/v1/dashboard/treasury.ts`): + // no Horizon URL configured is a per-request 503, not a boot failure. + let Some(horizon_url) = state.config.stellar_horizon_url.as_deref() else { + return Err(ApiError::ServiceUnavailable( + "no Horizon URL configured".into(), + )); + }; + let url = format!("{horizon_url}/accounts/{address}"); let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(10)) @@ -76,11 +83,8 @@ pub async fn get_treasury( .await .map_err(|e| ApiError::ServiceUnavailable(format!("horizon unreachable: {e}")))?; - // 404 means the account is not funded yet — a real, reportable state, and - // not something the operator should see as a server error. - if resp.status() == reqwest::StatusCode::NOT_FOUND { - return Err(ApiError::NotFound); - } + // Any non-OK from Horizon — including 404 for a not-yet-funded account — is + // a blanket 503, matching the reference implementation's error semantics. if !resp.status().is_success() { return Err(ApiError::ServiceUnavailable(format!( "horizon returned {}", diff --git a/crates/api/tests/common/mod.rs b/crates/api/tests/common/mod.rs index 3e0c991..03b6e2a 100644 --- a/crates/api/tests/common/mod.rs +++ b/crates/api/tests/common/mod.rs @@ -97,7 +97,7 @@ pub fn build_test_app_state( network: "standalone".into(), network_fee: 1_000_000, stellar_rpc_url: String::new(), - stellar_horizon_url: String::new(), + stellar_horizon_url: None, transaction_expiration_offset: 1_000, event_watcher_interval: Duration::from_millis(30_000), service_domain: service_domain.into(), diff --git a/crates/api/tests/executor_tick.rs b/crates/api/tests/executor_tick.rs index dddb9a9..3330365 100644 --- a/crates/api/tests/executor_tick.rs +++ b/crates/api/tests/executor_tick.rs @@ -151,7 +151,7 @@ fn cfg(rpc_url: &str) -> Arc { network: "standalone".into(), network_fee: 1_000_000, stellar_rpc_url: rpc_url.into(), - stellar_horizon_url: String::new(), + stellar_horizon_url: None, transaction_expiration_offset: 1_000, event_watcher_interval: StdDuration::from_millis(30_000), service_domain: "smoke.local".into(), diff --git a/crates/api/tests/mempool_tick.rs b/crates/api/tests/mempool_tick.rs index 6cb0eaf..4670111 100644 --- a/crates/api/tests/mempool_tick.rs +++ b/crates/api/tests/mempool_tick.rs @@ -33,7 +33,7 @@ fn cfg_with_capacity(capacity: usize) -> Arc { network: "standalone".into(), network_fee: 1_000_000, stellar_rpc_url: String::new(), - stellar_horizon_url: String::new(), + stellar_horizon_url: None, transaction_expiration_offset: 1_000, event_watcher_interval: StdDuration::from_millis(30_000), service_domain: "smoke.local".into(), diff --git a/crates/api/tests/sep10_http.rs b/crates/api/tests/sep10_http.rs index d60596f..68cef87 100644 --- a/crates/api/tests/sep10_http.rs +++ b/crates/api/tests/sep10_http.rs @@ -50,7 +50,7 @@ fn make_test_state(pp_seed: [u8; 32], operator_pubkey_strkey: String) -> AppStat network: "standalone".into(), network_fee: 1_000_000, stellar_rpc_url: String::new(), - stellar_horizon_url: String::new(), + stellar_horizon_url: None, transaction_expiration_offset: 1_000, event_watcher_interval: Duration::from_millis(30_000), service_domain: "smoke.local".into(), diff --git a/crates/api/tests/verifier_tick.rs b/crates/api/tests/verifier_tick.rs index 63b33c0..c629406 100644 --- a/crates/api/tests/verifier_tick.rs +++ b/crates/api/tests/verifier_tick.rs @@ -42,7 +42,7 @@ fn cfg() -> Arc { network: "standalone".into(), network_fee: 1_000_000, stellar_rpc_url: String::new(), - stellar_horizon_url: String::new(), + stellar_horizon_url: None, transaction_expiration_offset: 1_000, event_watcher_interval: StdDuration::from_millis(30_000), service_domain: "smoke.local".into(), diff --git a/crates/api/tests/ws_events_e2e.rs b/crates/api/tests/ws_events_e2e.rs index 00d0798..b94f192 100644 --- a/crates/api/tests/ws_events_e2e.rs +++ b/crates/api/tests/ws_events_e2e.rs @@ -56,7 +56,7 @@ fn make_state(events: EventBroadcaster) -> AppState { network: "standalone".into(), network_fee: 1_000_000, stellar_rpc_url: String::new(), - stellar_horizon_url: String::new(), + stellar_horizon_url: None, transaction_expiration_offset: 1_000, event_watcher_interval: Duration::from_millis(30_000), service_domain: "smoke.local".into(), diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 9ae5678..05e0610 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -15,13 +15,15 @@ pub struct Config { pub stellar_rpc_url: String, /// Horizon base URL (no trailing path), e.g. `https://horizon-testnet.stellar.org`. /// - /// REQUIRED and explicit. It is deliberately NOT derived from - /// `stellar_rpc_url`: Horizon and Soroban RPC only share a host on the - /// local quickstart (`:8000` serves both). Deriving one from the other by - /// string surgery works locally and silently points at the wrong service on - /// testnet/mainnet, where they are separate deployments - /// (`soroban-testnet.stellar.org` vs `horizon-testnet.stellar.org`). - pub stellar_horizon_url: String, + /// Optional at boot, mirroring the reference (`NETWORK_CONFIG.horizonUrl` in + /// provider-platform): when unset, `GET /provider/treasury` answers 503 per + /// request. It is deliberately NOT derived from `stellar_rpc_url`: Horizon + /// and Soroban RPC only share a host on the local quickstart (`:8000` serves + /// both). Deriving one from the other by string surgery works locally and + /// silently points at the wrong service on testnet/mainnet, where they are + /// separate deployments (`soroban-testnet.stellar.org` vs + /// `horizon-testnet.stellar.org`). + pub stellar_horizon_url: Option, pub transaction_expiration_offset: u32, pub event_watcher_interval: Duration, pub service_domain: String, @@ -61,9 +63,10 @@ impl Config { default_rpc_for_network(env::var("NETWORK").unwrap_or_default().as_str()) .to_string() }), - stellar_horizon_url: required("STELLAR_HORIZON_URL")? - .trim_end_matches('/') - .to_string(), + stellar_horizon_url: env::var("STELLAR_HORIZON_URL") + .ok() + .map(|v| v.trim_end_matches('/').to_string()) + .filter(|v| !v.is_empty()), transaction_expiration_offset: env_or("TRANSACTION_EXPIRATION_OFFSET", "1000")? .parse()?, event_watcher_interval: Duration::from_millis(