Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion packages/core/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
64 changes: 10 additions & 54 deletions packages/core/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<PeerIpKeyExtractor, NoOpMiddleware> {
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()
Expand Down Expand Up @@ -88,15 +54,13 @@ 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());

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());

Expand All @@ -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");

Expand Down
56 changes: 35 additions & 21 deletions packages/core/src/models/operation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<String>,
/// The claimable balance ID being clawed back.
pub balance_id: String,
}

Expand Down Expand Up @@ -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) {
Expand All @@ -198,7 +199,7 @@ fn format_asset(asset_type: Option<&str>, asset_code: Option<&str>, asset_issuer

impl From<HorizonOperation> 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()),
Expand All @@ -212,9 +213,14 @@ impl From<HorizonOperation> 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<u32> — 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,
Expand All @@ -235,7 +241,7 @@ impl From<HorizonOperation> 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(),
Expand All @@ -253,7 +259,10 @@ impl From<HorizonOperation> 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::<u64>().ok())
.unwrap_or(0),
offer_type: OfferType::Sell,
})
}
Expand All @@ -275,7 +284,10 @@ impl From<HorizonOperation> 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::<u64>().ok())
.unwrap_or(0),
offer_type: OfferType::Buy,
})
}
Expand All @@ -298,7 +310,7 @@ impl From<HorizonOperation> 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,
})
}
Expand All @@ -321,7 +333,7 @@ impl From<HorizonOperation> 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,
})
}
Expand All @@ -333,14 +345,16 @@ impl From<HorizonOperation> 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,
}),
}
}
Expand Down
81 changes: 80 additions & 1 deletion packages/core/src/routes/account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
pub org_name: Option<String>,
pub flag_descriptions: Vec<String>,
}

#[derive(Debug, Deserialize)]
pub struct AccountTransactionsQuery {
pub limit: Option<u32>,
Expand Down Expand Up @@ -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<String>,
State(horizon_client): State<Arc<HorizonClient>>,
Extension(request_id): Extension<RequestId>,
) -> Result<Json<AccountExplanationResponse>, 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::*;
Expand Down Expand Up @@ -222,4 +301,4 @@ mod tests {
let cursor: Option<String> = Some("157639564177408001".to_string());
assert_eq!(cursor.as_deref(), Some("157639564177408001"));
}
}
}
1 change: 0 additions & 1 deletion packages/core/src/routes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ use utoipa::OpenApi;
paths(
health::health,
tx::get_tx_explanation,
tx::get_tx_raw
),
components(
schemas(
Expand Down
Loading
Loading