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: 1 addition & 0 deletions packages/core/Cargo.lock

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

1 change: 1 addition & 0 deletions packages/core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ 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"] }
Expand Down
18 changes: 18 additions & 0 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,24 @@ Expected response:
ok
```

### GET /tx/:hash

Returns a human-readable explanation of a Stellar transaction.

```bash
curl http://localhost:4000/tx/<transaction-hash>
```

### GET /tx/:hash/raw

Returns the raw, unprocessed JSON response from Horizon for the given transaction hash. Useful for developers and power users who want direct access to the full Horizon data.

```bash
curl http://localhost:4000/tx/<transaction-hash>/raw
```

Applies the same error handling as `/tx/:hash` — 400 for invalid hashes, 404 for not found, 502 for upstream failures.

---

## 🧪 Testing
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ async fn main() {
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(
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/routes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ use utoipa::OpenApi;
#[openapi(
paths(
health::health,
tx::get_tx_explanation
tx::get_tx_explanation,
tx::get_tx_raw
),
components(
schemas(
Expand Down
82 changes: 82 additions & 0 deletions packages/core/src/routes/tx.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
use axum::{
body::Body,
extract::{Extension, Path, State},
http::{header, StatusCode},
response::Response,
Json,
};
use serde::Serialize;
Expand Down Expand Up @@ -166,4 +169,83 @@ 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<String>,
State(horizon_client): State<Arc<HorizonClient>>,
Extension(request_id): Extension<RequestId>,
) -> Result<Response, AppError> {
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"))
}
24 changes: 24 additions & 0 deletions packages/core/src/services/horizon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,30 @@ 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<bytes::Bytes, HorizonError> {
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);
Expand Down
49 changes: 49 additions & 0 deletions packages/core/src/services/horizon_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,55 @@ mod tests {
matches!(err, crate::errors::HorizonError::AccountNotFound);
}

#[tokio::test]
async fn fetch_transaction_raw_proxies_horizon_response() {
let server = MockServer::start();
let raw_body =
r#"{"hash":"abc123","successful":true,"fee_charged":"100","ledger":12345}"#;

server.mock(|when, then| {
when.method(GET).path("/transactions/abc123");
then.status(200)
.header("content-type", "application/json")
.body(raw_body);
});

let client = HorizonClient::new(server.base_url());
let bytes = client.fetch_transaction_raw("abc123").await.unwrap();

assert_eq!(bytes.as_ref(), raw_body.as_bytes());
}

#[tokio::test]
async fn fetch_transaction_raw_not_found() {
let server = MockServer::start();

server.mock(|when, then| {
when.method(GET).path("/transactions/missing");
then.status(404);
});

let client = HorizonClient::new(server.base_url());
let err = client.fetch_transaction_raw("missing").await.unwrap_err();

matches!(err, crate::errors::HorizonError::TransactionNotFound);
}

#[tokio::test]
async fn fetch_transaction_raw_upstream_error() {
let server = MockServer::start();

server.mock(|when, then| {
when.method(GET).path("/transactions/bad");
then.status(500);
});

let client = HorizonClient::new(server.base_url());
let err = client.fetch_transaction_raw("bad").await.unwrap_err();

matches!(err, crate::errors::HorizonError::InvalidResponse);
}

#[tokio::test]
async fn fetch_stellar_toml_org_name_with_cache() {
let server = MockServer::start();
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/services/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,6 @@ pub mod explain;
pub mod horizon;
pub mod labels;
pub mod transaction_cache;

#[cfg(test)]
mod horizon_test;
Loading