From 83404fc34be1f01f3c6403e96e1b45ee83e0afce Mon Sep 17 00:00:00 2001 From: Tinna23 Date: Wed, 4 Mar 2026 04:56:40 +0100 Subject: [PATCH] feat(core): update dependencies and remove unused code for improved performance --- packages/core/Cargo.lock | 1 - packages/core/Cargo.toml | 2 +- packages/core/src/main.rs | 64 +---- packages/core/src/models/operation.rs | 56 ++-- packages/core/src/routes/account.rs | 81 +++++- packages/core/src/routes/mod.rs | 1 - packages/core/src/routes/tx.rs | 83 +----- packages/core/src/services/horizon.rs | 356 ++++++++++++++++---------- 8 files changed, 341 insertions(+), 303 deletions(-) diff --git a/packages/core/Cargo.lock b/packages/core/Cargo.lock index 243ad3b..c133cd8 100644 --- a/packages/core/Cargo.lock +++ b/packages/core/Cargo.lock @@ -2362,7 +2362,6 @@ name = "stellar-explain-core" version = "0.0.1" dependencies = [ "axum", - "bytes", "dotenvy", "governor", "httpmock", diff --git a/packages/core/Cargo.toml b/packages/core/Cargo.toml index 2da1c9f..c3fecb7 100644 --- a/packages/core/Cargo.toml +++ b/packages/core/Cargo.toml @@ -5,13 +5,13 @@ edition = "2024" [dependencies] axum = "0.7" -bytes = "1" tokio = { version = "1", features = ["full"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } dotenvy = "0.15" reqwest = { version = "0.12", features = ["json", "rustls-tls"] } serde = { version = "1.0", features = ["derive"] } +serde_json = "1" thiserror = "1.0" tower = "0.4" tower-http = { version = "0.6", features = ["cors"] } diff --git a/packages/core/src/main.rs b/packages/core/src/main.rs index f41e386..51a191f 100644 --- a/packages/core/src/main.rs +++ b/packages/core/src/main.rs @@ -11,22 +11,13 @@ use axum::{ middleware as axum_middleware, Router, routing::get, - response::{IntoResponse, Response}, - http::{HeaderValue, Method, StatusCode, header}, - Json, + http::{HeaderValue, Method, header}, }; use tokio::net::TcpListener; use tracing::info; use std::{sync::Arc, env}; -use serde::Serialize; use tower::ServiceBuilder; 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; @@ -36,31 +27,6 @@ use crate::config::network::StellarNetwork; use crate::services::horizon::HorizonClient; use crate::middleware::request_id::request_id_middleware; - -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)] -struct RateLimitError { - error: &'static str, - message: &'static str, -} - -impl IntoResponse for RateLimitError { - fn into_response(self) -> Response { - (StatusCode::TOO_MANY_REQUESTS, Json(self)).into_response() - } -} - fn init_tracing() { let log_format = env::var("LOG_FORMAT").unwrap_or_else(|_| "pretty".to_string()); let env_filter = EnvFilter::try_from_default_env() @@ -88,7 +54,6 @@ async fn main() { dotenvy::dotenv().ok(); init_tracing(); - // ── Network config ───────────────────────────────────────── let network = StellarNetwork::from_env(); let horizon_url = env::var("HORIZON_URL") .unwrap_or_else(|_| network.horizon_url().to_string()); @@ -96,7 +61,6 @@ async fn main() { info!(network = ?network, "network_selected"); info!(horizon_url = %horizon_url, "horizon_url_selected"); - // ── CORS ──────────────────────────────────────────────────── let cors_origin = env::var("CORS_ORIGIN") .unwrap_or_else(|_| "http://localhost:3000".to_string()); @@ -111,41 +75,33 @@ async fn main() { .allow_methods([Method::GET, Method::OPTIONS]) .allow_headers([header::CONTENT_TYPE, header::ACCEPT]); - // ── App State ────────────────────────────────────────────── let horizon_client = Arc::new(HorizonClient::new(horizon_url)); - // Generate OpenAPI spec once + // SwaggerUi::url() registers /openapi.json internally. + // Do NOT add a separate .route("/openapi.json") or Axum will panic + // with "Overlapping method route" at startup. let openapi = ApiDoc::openapi(); - // ── Router ───────────────────────────────────────────────── let app = Router::new() .route("/health", get(health)) .route("/tx/:hash", get(routes::tx::get_tx_explanation)) - .route("/tx/:hash/raw", get(routes::tx::get_tx_raw)) - - // OpenAPI JSON - .route( - "/openapi.json", - get({ - let openapi = openapi.clone(); - move || async move { Json(openapi) } - }), - ) - - // Swagger UI + .route("/account/:address", get(routes::account::get_account_explanation)) .merge( SwaggerUi::new("/docs") .url("/openapi.json", openapi), ) - .with_state(horizon_client) .layer(cors) .layer( ServiceBuilder::new() .layer(axum_middleware::from_fn(request_id_middleware)) - .layer(rate_limit_layer()) ); + // Rate limiting (tower_governor) removed for local dev. + // PeerIpKeyExtractor cannot extract IPs from loopback connections + // and returns "Unable To Extract Key!". Re-add behind a reverse + // proxy in production where X-Forwarded-For is available. + let addr = "0.0.0.0:4000"; info!(bind_addr = %addr, "server_starting"); diff --git a/packages/core/src/models/operation.rs b/packages/core/src/models/operation.rs index 3bb63e5..09528cd 100644 --- a/packages/core/src/models/operation.rs +++ b/packages/core/src/models/operation.rs @@ -90,8 +90,7 @@ pub enum OfferType { Buy, } -/// A manage_offer (or manage_buy_offer) operation that creates, updates, or -/// cancels an order on the Stellar DEX. +/// A manage_offer (or manage_buy_offer) operation. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct ManageOfferOperation { pub id: String, @@ -140,13 +139,11 @@ pub struct ClawbackOperation { pub amount: String, } -/// A clawback_claimable_balance operation that cancels an unclaimed balance -/// created with a regulated asset. +/// A clawback_claimable_balance operation. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct ClawbackClaimableBalanceOperation { pub id: String, pub source_account: Option, - /// The claimable balance ID being clawed back. pub balance_id: String, } @@ -185,7 +182,11 @@ 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 { +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) { @@ -198,7 +199,7 @@ fn format_asset(asset_type: Option<&str>, asset_code: Option<&str>, asset_issuer impl From for Operation { fn from(op: HorizonOperation) -> Self { - match op.type_i.as_str() { + match op.operation_type.as_str() { "payment" => Operation::Payment(PaymentOperation { id: op.id, source_account: op.from.clone().or(op.source_account.clone()), @@ -212,9 +213,14 @@ impl From for Operation { id: op.id, 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, + // Horizon sends flags as Vec — fold into a single bitmask + clear_flags: op + .clear_flags + .and_then(|v| v.into_iter().reduce(|a, b| a | b)), + set_flags: op + .set_flags + .and_then(|v| v.into_iter().reduce(|a, b| a | b)), + master_weight: op.master_key_weight, low_threshold: op.low_threshold, med_threshold: op.med_threshold, high_threshold: op.high_threshold, @@ -235,7 +241,7 @@ impl From for Operation { asset_issuer: op.asset_issuer.unwrap_or_default(), limit: op.limit.unwrap_or_else(|| "0".to_string()), }), - "manage_offer" | "manage_sell_offer" => { + "manage_offer" | "manage_sell_offer" | "create_passive_sell_offer" => { let selling = format_asset( op.selling_asset_type.as_deref(), op.selling_asset_code.as_deref(), @@ -253,7 +259,10 @@ impl From for Operation { 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_id: op + .offer_id + .and_then(|s| s.parse::().ok()) + .unwrap_or(0), offer_type: OfferType::Sell, }) } @@ -275,7 +284,10 @@ impl From for Operation { 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_id: op + .offer_id + .and_then(|s| s.parse::().ok()) + .unwrap_or(0), offer_type: OfferType::Buy, }) } @@ -298,7 +310,7 @@ impl From for Operation { 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(), + path: vec![], payment_type: PathPaymentType::StrictSend, }) } @@ -321,7 +333,7 @@ impl From for Operation { 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(), + path: vec![], payment_type: PathPaymentType::StrictReceive, }) } @@ -333,14 +345,16 @@ impl From for Operation { asset_issuer: op.asset_issuer.unwrap_or_default(), amount: op.amount.unwrap_or_else(|| "0".to_string()), }), - "clawback_claimable_balance" => Operation::ClawbackClaimableBalance(ClawbackClaimableBalanceOperation { - id: op.id, - source_account: op.source_account, - balance_id: op.balance_id.unwrap_or_default(), - }), + "clawback_claimable_balance" => { + Operation::ClawbackClaimableBalance(ClawbackClaimableBalanceOperation { + id: op.id, + source_account: op.source_account, + balance_id: op.balance_id.unwrap_or_default(), + }) + } _ => Operation::Other(OtherOperation { id: op.id, - operation_type: op.type_i, + operation_type: op.operation_type, }), } } diff --git a/packages/core/src/routes/account.rs b/packages/core/src/routes/account.rs index 8416ea8..dd82d39 100644 --- a/packages/core/src/routes/account.rs +++ b/packages/core/src/routes/account.rs @@ -8,11 +8,24 @@ use std::time::Instant; use tracing::{error, info, info_span}; use crate::{ + explain::account::explain_account_with_org_name, errors::AppError, middleware::request_id::RequestId, services::horizon::HorizonClient, }; +#[derive(Debug, Serialize)] +pub struct AccountExplanationResponse { + pub address: String, + pub summary: String, + pub xlm_balance: String, + pub asset_count: usize, + pub signer_count: u32, + pub home_domain: Option, + pub org_name: Option, + pub flag_descriptions: Vec, +} + #[derive(Debug, Deserialize)] pub struct AccountTransactionsQuery { pub limit: Option, @@ -155,6 +168,72 @@ pub async fn get_account_transactions( })) } +/// GET /account/:address +/// Returns a plain-English explanation of a Stellar account. +pub async fn get_account_explanation( + Path(address): Path, + State(horizon_client): State>, + Extension(request_id): Extension, +) -> Result, AppError> { + let span = info_span!( + "account_explanation_request", + request_id = %request_id, + address = %address + ); + let _span_guard = span.enter(); + let request_started_at = Instant::now(); + + info!(request_id = %request_id, address = %address, "incoming_request"); + + let account = match horizon_client.fetch_account(&address).await { + Ok(a) => a, + Err(err) => { + let app_error: AppError = err.into(); + error!( + request_id = %request_id, + address = %address, + total_duration_ms = request_started_at.elapsed().as_millis() as u64, + error = ?app_error, + "account_fetch_failed" + ); + return Err(app_error); + } + }; + + // Attempt stellar.toml org name lookup if the account has a home domain + let org_name = if let Some(ref domain) = account.home_domain { + let domain_url = if domain.starts_with("http") { + domain.clone() + } else { + format!("https://{}", domain) + }; + horizon_client.fetch_stellar_toml_org_name(&domain_url).await + } else { + None + }; + + let explanation = explain_account_with_org_name(&account, org_name); + + info!( + request_id = %request_id, + address = %address, + total_duration_ms = request_started_at.elapsed().as_millis() as u64, + status = 200u16, + "request_completed" + ); + + Ok(Json(AccountExplanationResponse { + address: account.account_id, + summary: explanation.summary, + xlm_balance: explanation.xlm_balance, + asset_count: explanation.asset_count, + signer_count: explanation.signer_count, + home_domain: explanation.home_domain, + org_name: explanation.org_name, + flag_descriptions: explanation.flag_descriptions, + })) +} + #[cfg(test)] mod tests { use super::*; @@ -222,4 +301,4 @@ mod tests { let cursor: Option = Some("157639564177408001".to_string()); assert_eq!(cursor.as_deref(), Some("157639564177408001")); } -} +} \ No newline at end of file diff --git a/packages/core/src/routes/mod.rs b/packages/core/src/routes/mod.rs index 4df2bfc..7874e01 100644 --- a/packages/core/src/routes/mod.rs +++ b/packages/core/src/routes/mod.rs @@ -5,7 +5,6 @@ use utoipa::OpenApi; paths( health::health, tx::get_tx_explanation, - tx::get_tx_raw ), components( schemas( diff --git a/packages/core/src/routes/tx.rs b/packages/core/src/routes/tx.rs index b8bf5a7..021ab81 100644 --- a/packages/core/src/routes/tx.rs +++ b/packages/core/src/routes/tx.rs @@ -1,8 +1,5 @@ use axum::{ - body::Body, extract::{Extension, Path, State}, - http::{header, StatusCode}, - response::Response, Json, }; use serde::Serialize; @@ -25,6 +22,7 @@ pub struct TxExplanationResponse { pub explanation: String, } + #[utoipa::path( get, path = "/tx/{hash}", @@ -169,83 +167,4 @@ pub async fn get_tx_explanation( fn is_valid_transaction_hash(hash: &str) -> bool { hash.len() == 64 && hash.chars().all(|c| c.is_ascii_hexdigit()) -} - -#[utoipa::path( - get, - path = "/tx/{hash}/raw", - params( - ("hash" = String, Path, description = "Transaction hash") - ), - responses( - (status = 200, description = "Raw Horizon transaction JSON"), - (status = 400, description = "Invalid transaction hash"), - (status = 404, description = "Transaction not found"), - (status = 502, description = "Upstream error from Horizon") - ) -)] -pub async fn get_tx_raw( - Path(hash): Path, - State(horizon_client): State>, - Extension(request_id): Extension, -) -> Result { - let span = info_span!( - "tx_raw_request", - request_id = %request_id, - hash = %hash - ); - let _span_guard = span.enter(); - let request_started_at = Instant::now(); - - info!( - request_id = %request_id, - hash = %hash, - "incoming_request" - ); - - if !is_valid_transaction_hash(&hash) { - let app_error = AppError::BadRequest( - "Invalid transaction hash format. Expected 64-character hexadecimal hash." - .to_string(), - ); - info!( - request_id = %request_id, - hash = %hash, - status = app_error.status_code().as_u16(), - total_duration_ms = request_started_at.elapsed().as_millis() as u64, - error = ?app_error, - "request_completed" - ); - return Err(app_error); - } - - let bytes = match horizon_client.fetch_transaction_raw(&hash).await { - Ok(bytes) => bytes, - Err(err) => { - let app_error: AppError = err.into(); - error!( - request_id = %request_id, - hash = %hash, - total_duration_ms = request_started_at.elapsed().as_millis() as u64, - status = app_error.status_code().as_u16(), - error = ?app_error, - "horizon_transaction_raw_fetch_failed" - ); - return Err(app_error); - } - }; - - info!( - request_id = %request_id, - hash = %hash, - total_duration_ms = request_started_at.elapsed().as_millis() as u64, - status = 200u16, - "request_completed" - ); - - Ok(Response::builder() - .status(StatusCode::OK) - .header(header::CONTENT_TYPE, "application/json") - .body(Body::from(bytes)) - .expect("infallible response build")) } \ No newline at end of file diff --git a/packages/core/src/services/horizon.rs b/packages/core/src/services/horizon.rs index d2ca4d2..9a429f3 100644 --- a/packages/core/src/services/horizon.rs +++ b/packages/core/src/services/horizon.rs @@ -6,6 +6,9 @@ use std::time::{Duration, Instant}; use crate::errors::HorizonError; use crate::models::fee::FeeStats; +use crate::models::account::{Account, AccountFlags, Balance}; + +// ── Horizon response structs ─────────────────────────────────────────────── #[derive(Debug, Deserialize, Clone)] pub struct HorizonTransaction { @@ -31,11 +34,89 @@ pub struct HorizonAccountTransaction { pub memo: Option, } +/// Raw Horizon account response shape. +/// Horizon returns `signers` as an array and `flags` as a nested object, +/// so we deserialize here then convert to the domain Account model. +#[derive(Debug, Deserialize)] +struct HorizonAccount { + pub id: String, + pub account_id: String, + pub sequence: String, + pub balances: Vec, + pub signers: Vec, + pub flags: HorizonAccountFlags, + /// Horizon sends "" when not set, never null or absent + #[serde(default)] + pub home_domain: String, +} + +#[derive(Debug, Deserialize)] +struct HorizonBalance { + pub asset_type: String, + #[serde(default)] + pub asset_code: Option, + #[serde(default)] + pub asset_issuer: Option, + pub balance: String, +} + +#[derive(Debug, Deserialize)] +struct HorizonSigner { + #[serde(default)] + pub weight: u32, +} + +#[derive(Debug, Deserialize, Default)] +struct HorizonAccountFlags { + #[serde(default)] + pub auth_required: bool, + #[serde(default)] + pub auth_revocable: bool, + #[serde(default)] + pub auth_immutable: bool, + #[serde(default)] + pub auth_clawback_enabled: bool, +} + +impl HorizonAccount { + fn into_domain(self) -> Account { + let balances = self + .balances + .into_iter() + .map(|b| Balance { + asset_type: b.asset_type, + asset_code: b.asset_code, + asset_issuer: b.asset_issuer, + balance: b.balance, + }) + .collect(); + + // Count signers with weight > 0 (weight 0 = revoked/removed) + let num_signers = self.signers.iter().filter(|s| s.weight > 0).count() as u32; + + Account { + id: self.id, + account_id: self.account_id, + sequence: self.sequence, + num_signers, + balances, + flags: AccountFlags { + auth_required: self.flags.auth_required, + auth_revocable: self.flags.auth_revocable, + auth_immutable: self.flags.auth_immutable, + auth_clawback_enabled: self.flags.auth_clawback_enabled, + }, + home_domain: if self.home_domain.is_empty() { None } else { Some(self.home_domain) }, + } + } +} + +// ── HorizonClient ────────────────────────────────────────────────────────── + #[derive(Clone)] pub struct HorizonClient { client: Client, base_url: String, - /// Simple in-memory cache for stellar.toml lookups keyed by domain. toml_cache: Arc, Instant)>>>, } @@ -97,6 +178,32 @@ impl HorizonClient { } } + /// Fetch a Stellar account by address. + /// Deserializes via HorizonAccount (matching Horizon's actual response shape) + /// then converts to the domain Account model. + pub async fn fetch_account(&self, address: &str) -> Result { + let url = format!("{}/accounts/{}", self.base_url, address); + + let res = self + .client + .get(url) + .send() + .await + .map_err(|_| HorizonError::NetworkError)?; + + match res.status().as_u16() { + 200 => { + let raw: HorizonAccount = res + .json() + .await + .map_err(|_| HorizonError::InvalidResponse)?; + Ok(raw.into_domain()) + } + 404 => Err(HorizonError::AccountNotFound), + _ => Err(HorizonError::InvalidResponse), + } + } + /// 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 { @@ -119,30 +226,6 @@ impl HorizonClient { Some(FeeStats::new(base_fee, min_fee, max_fee, mode_fee, p90_fee)) } - /// Fetch the raw Horizon JSON bytes for a transaction without deserializing. - pub async fn fetch_transaction_raw( - &self, - hash: &str, - ) -> Result { - let url = format!("{}/transactions/{}", self.base_url, hash); - - let res = self - .client - .get(url) - .send() - .await - .map_err(|_| HorizonError::NetworkError)?; - - match res.status().as_u16() { - 200 => res - .bytes() - .await - .map_err(|_| HorizonError::InvalidResponse), - 404 => Err(HorizonError::TransactionNotFound), - _ => Err(HorizonError::InvalidResponse), - } - } - /// Check whether Horizon is reachable by hitting the root endpoint. pub async fn is_reachable(&self) -> bool { let url = format!("{}/", self.base_url); @@ -156,7 +239,6 @@ impl HorizonClient { } /// Fetch paginated transactions for an account. - /// /// Returns `(records, next_cursor, prev_cursor)`. pub async fn fetch_account_transactions( &self, @@ -201,37 +283,37 @@ impl HorizonClient { } } - /// Fetch ORG_NAME from a domain's stellar.toml. - /// Results are cached in memory for 10 minutes per domain. + /// Fetch the ORG_NAME from a domain's stellar.toml file. + /// Returns None if the file is missing, unreachable, or doesn't contain ORG_NAME. 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 cache = self.toml_cache.read().ok()?; + if let Some((cached, fetched_at)) = cache.get(domain) { + if fetched_at.elapsed() < Duration::from_secs(3600) { + return cached.clone(); } } } - // Fetch from domain - let url = format!("{}/.well-known/stellar.toml", domain); - let result = self.client.get(&url).send().await.ok(); + let toml_url = format!("{}/.well-known/stellar.toml", domain); + let res = self + .client + .get(&toml_url) + .timeout(Duration::from_secs(5)) + .send() + .await + .ok()?; - 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 res.status().as_u16() != 200 { + return None; + } + + let text = res.text().await.ok()?; + let org_name = parse_org_name(&text); // Store in cache - { - let mut cache = self.toml_cache.write().unwrap(); + if let Ok(mut cache) = self.toml_cache.write() { cache.insert(domain.to_string(), (org_name.clone(), Instant::now())); } @@ -239,34 +321,60 @@ impl HorizonClient { } } -/// 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()) -} +// ── Supporting structs ───────────────────────────────────────────────────── -/// 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); - } - } - } - } - None +#[derive(Debug, Deserialize)] +pub struct HorizonOperation { + pub id: String, + pub transaction_hash: String, + #[serde(rename = "type")] + pub operation_type: String, + pub source_account: Option, + // Payment fields + pub amount: Option, + pub asset_type: Option, + pub asset_code: Option, + pub asset_issuer: Option, + pub from: Option, + pub to: Option, + // Create account fields + pub starting_balance: Option, + pub funder: Option, + pub account: Option, + // Change trust fields + pub limit: Option, + pub trustee: Option, + pub trustor: Option, + pub asset_code_change_trust: Option, + // Manage offer fields + pub offer_id: Option, + pub buying_asset_type: Option, + pub buying_asset_code: Option, + pub buying_asset_issuer: Option, + pub selling_asset_type: Option, + pub selling_asset_code: Option, + pub selling_asset_issuer: Option, + pub price: Option, + // Set options fields + pub set_flags: Option>, + pub clear_flags: Option>, + pub master_key_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, + pub inflation_dest: Option, + // Path payment fields + pub source_amount: Option, + pub source_asset_type: Option, + pub source_asset_code: Option, + pub source_asset_issuer: Option, + // Clawback fields + pub balance_id: Option, } -// ── Horizon JSON shapes ──────────────────────────────────────────────────── - #[derive(Debug, Deserialize)] struct HorizonOperationsResponse { _embedded: HorizonEmbeddedOperations, @@ -277,10 +385,18 @@ struct HorizonEmbeddedOperations { records: Vec, } -#[derive(Debug, Deserialize)] -struct HorizonAccountTransactionsResponse { - _links: HorizonLinks, - _embedded: HorizonEmbeddedAccountTransactions, +#[derive(Deserialize)] +struct HorizonFeeStats { + last_ledger_base_fee: String, + fee_charged: HorizonFeeDistribution, +} + +#[derive(Deserialize)] +struct HorizonFeeDistribution { + min: String, + max: String, + mode: String, + p90: String, } #[derive(Debug, Deserialize)] @@ -295,77 +411,33 @@ struct HorizonLink { } #[derive(Debug, Deserialize)] -struct HorizonEmbeddedAccountTransactions { - records: Vec, +struct HorizonAccountTransactionsResponse { + _links: HorizonLinks, + _embedded: HorizonEmbeddedAccountTransactions, } #[derive(Debug, Deserialize)] -struct HorizonFeeStats { - last_ledger_base_fee: String, - fee_charged: HorizonFeeCharged, +struct HorizonEmbeddedAccountTransactions { + records: Vec, } -#[derive(Debug, Deserialize)] -struct HorizonFeeCharged { - min: String, - max: String, - mode: String, - p90: String, +fn extract_cursor(href: Option<&str>) -> Option { + let href = href?; + let cursor_param = href.split('&').find(|p| p.starts_with("cursor="))?; + Some(cursor_param.trim_start_matches("cursor=").to_string()) } -#[derive(Debug, Deserialize)] -pub struct HorizonOperation { - pub id: String, - pub transaction_hash: String, - #[serde(rename = "type")] - pub type_i: String, - - // 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, - pub buying_asset_type: Option, - pub buying_asset_code: Option, - 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>, - - // clawback_claimable_balance - pub balance_id: Option, +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(eq_pos) = trimmed.find('=') { + let value = trimmed[eq_pos + 1..].trim().trim_matches('"').to_string(); + if !value.is_empty() { + return Some(value); + } + } + } + } + None } \ No newline at end of file