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
8 changes: 6 additions & 2 deletions contract/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,16 @@ STELLAR_SECRET_KEY=
# Redis connection URL. [default: redis://127.0.0.1:6379]
REDIS_URL=redis://127.0.0.1:6379

# Sustained rate limit (requests per second). [default: 10]
# Sustained verification/other-route quota (requests per second). [default: 10]
RATE_LIMIT_PER_SECOND=10

# Burst rate limit (concurrent requests allowed). [default: same as RATE_LIMIT_PER_SECOND]
# Burst quota for verification/other routes. [default: 10]
RATE_LIMIT_BURST=10

# Lower, independent quota for Stellar document submissions.
SUBMIT_RATE_LIMIT_PER_SECOND=1
SUBMIT_RATE_LIMIT_BURST=2

# Maximum number of retries for Stellar Horizon requests. [default: 3]
STELLAR_MAX_RETRIES=3

Expand Down
42 changes: 27 additions & 15 deletions contract/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,10 @@ smart contract.

All request/response bodies are JSON. There is currently **no
authentication or authorization middleware** on any route: every endpoint
below is open to any caller who can reach the service. There is also no
rate-limiting middleware currently active (see [Known issues](#known-issues)).
below is open to any caller who can reach the service. Rate limits are active
but process-wide rather than per-client; see [Known issues](#known-issues).
The versioned API is mounted at `/v1`. The unprefixed paths shown below remain
available as compatibility aliases; new clients should use the `/v1` prefix.

### `GET /health`

Expand Down Expand Up @@ -269,39 +271,47 @@ Redis (keyed as `transfer:<document_hash>`, retained ~10 years).
| `500` | Failed to derive the anchor account, read/write transfer history in cache |
| `502` | Horizon rejected or failed to submit the transfer transaction (surfaced as `500`, not `502`, in the current handler: see [Known issues](#known-issues)) |

### `GET /transfer/:document_hash`

Returns the cached transfer records for a document as a JSON array. Returns
an empty array when there is no recorded transfer history. The versioned path
is `/v1/transfer/:document_hash`.

## Module layout

| Module | Responsibility |
|---|---|
| `main.rs` | Binary entry point: loads config, initializes tracing, constructs `AppState`, starts the Axum server. |
| `lib.rs` | `AppState`, all HTTP request/response types, the `app()` router, and every route handler. |
| `lib.rs` | `AppState`, HTTP request/response types, middleware, and route handlers. |
| `routes.rs` | Maps versioned and compatibility URL paths to handlers and composes request-ID, tracing, and rate-limit middleware. |
| `config.rs` | `AppConfig::from_env()`: reads and validates all environment variables into a single typed config, collecting *all* validation errors before failing rather than stopping at the first one. |
| `stellar.rs` | `StellarClient`: all Horizon HTTP interaction: fetching account state, building/signing/submitting `ManageData` transactions for anchoring, revoking, and transferring, and reading operation history. Also owns the `ManageData` key-naming scheme (`doc_`, `trf_`, `revoked_` prefixes). |
| `cache.rs` | `CacheBackend`: a small abstraction over Redis (`RedisCache`) or an in-process `HashMap` (`InMemoryCache`), used for verification results and transfer/verification history. |
| `hash_validator.rs` | `HashValidator`: normalizes and validates hex-encoded SHA-256/SHA-512 hashes, with structured validation errors. |
| `metrics.rs` | `MetricsRegistry`: Prometheus counters (requests, cache hits/misses, errors) and text-format rendering for `/metrics`. |
| `rate_limit.rs` | A `governor`-based rate limiter builder. **Not currently wired into `app()`**: see [Known issues](#known-issues). |
| `rate_limit.rs` | Builds the in-memory request quotas used by `routes.rs`. |
| `site.rs` | Unused legacy metrics registry; the active metrics implementation is `metrics.rs`. |

## Known issues

Documenting these here rather than silently working around them, since a
new contributor hitting them would otherwise reasonably assume they're
missing something:

- **`rate_limit.rs` is dead code.** `build_rate_limiter()` is never called
from `main.rs` or `app()`. `AppConfig` still parses
`RATE_LIMIT_PER_SECOND` / `RATE_LIMIT_BURST`, but neither value is
currently enforced anywhere.
- **Rate limits are not per-client.** Verification/other routes share a
process-wide quota and submissions have a separate, lower process-wide
quota. The service has no authenticated caller identity, so it cannot
enforce a robust per-account limit; caller-supplied fields and forwarded-IP
headers are not trustworthy substitutes. Add authentication before relying
on per-client quotas, and configure upstream abuse protection as needed.
- **`event.rs` is not part of the compiled crate.** `lib.rs` does not
declare `pub mod event;`, and the file references `crate::error::Result`
/ `crate::error::AuditError`, which don't exist in this crate. It appears
to be leftover/orphaned code from a different module structure.
- **`transfer_document` and `get_transfer_history` in `lib.rs` are unused.**
The live `app()` router wires `/transfer` to `record_transfer`, not to
`transfer_document` (which always returns "not yet implemented"). There
is no live route for reading transfer history back out: only
`record_transfer`'s write path and `verify_document_history`'s (separate)
read path are actually routed.
The router wires `/transfer` to `record_transfer`; the `transfer_document`
handler remains unimplemented. Transfer-history reads use the separate
implementation in `handlers/transfer.rs`.
- **No authentication.** No route requires any credential today, despite
`submit`/`revoke`/`transfer` all being state-changing, chain-writing
operations.
Expand Down Expand Up @@ -369,8 +379,10 @@ docker run -p 6379:6379 redis:7
| `STELLAR_HORIZON_URL` | no | `https://horizon-testnet.stellar.org` | Must be a valid URL. |
| `STELLAR_SECRET_KEY` | **yes** | none | 56-character Stellar secret seed, starts with `S`. The single account all `ManageData` anchors are written to. |
| `REDIS_URL` | no | `redis://127.0.0.1:6379` | |
| `RATE_LIMIT_PER_SECOND` | no | `10` | Parsed and validated but not currently enforced: see [Known issues](#known-issues). |
| `RATE_LIMIT_BURST` | no | same as `RATE_LIMIT_PER_SECOND` | Same caveat. |
| `RATE_LIMIT_PER_SECOND` | no | `10` | Verification and other non-submit routes; process-wide per instance. |
| `RATE_LIMIT_BURST` | no | same as `RATE_LIMIT_PER_SECOND` | Non-zero burst capacity for verification and other routes. |
| `SUBMIT_RATE_LIMIT_PER_SECOND` | no | `1` | Separate, lower process-wide quota for document submissions. |
| `SUBMIT_RATE_LIMIT_BURST` | no | `2` | Non-zero burst capacity for document submissions. |
| `STELLAR_MAX_RETRIES` | no | `3` | |
| `LOG_LEVEL` | no | `info` | Used as the default `tracing` filter if `RUST_LOG` isn't set. |
| `WEBHOOK_URLS` | no | (empty) | Comma-separated. Parsed but currently unused. |
Expand Down
60 changes: 59 additions & 1 deletion contract/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ pub struct AppConfig {
pub redis_url: String,
pub rate_limit_per_second: u32,
pub rate_limit_burst: u32,
pub submit_rate_limit_per_second: u32,
pub submit_rate_limit_burst: u32,
pub stellar_max_retries: u32,
pub log_level: String,
pub environment: Environment,
Expand Down Expand Up @@ -171,6 +173,20 @@ impl AppConfig {
&mut errors,
&placeholders,
);
let submit_rate_limit_per_second_raw = get_env_or_default(
"SUBMIT_RATE_LIMIT_PER_SECOND",
"1",
is_production,
&mut errors,
&placeholders,
);
let submit_rate_limit_burst_raw = get_env_or_default(
"SUBMIT_RATE_LIMIT_BURST",
"2",
is_production,
&mut errors,
&placeholders,
);
let stellar_max_retries_raw = get_env_or_default(
"STELLAR_MAX_RETRIES",
"3",
Expand Down Expand Up @@ -235,7 +251,11 @@ impl AppConfig {
};

let rate_limit_burst: u32 = match rate_limit_burst_raw.parse() {
Ok(v) => v,
Ok(v) if v > 0 => v,
Ok(_) => {
errors.push("RATE_LIMIT_BURST must be greater than 0".to_string());
rate_limit_per_second
}
Err(_) => {
errors.push(format!(
"RATE_LIMIT_BURST must be a valid u32, got '{}'",
Expand All @@ -245,6 +265,36 @@ impl AppConfig {
}
};

let submit_rate_limit_per_second: u32 = match submit_rate_limit_per_second_raw.parse() {
Ok(v) if v > 0 => v,
Ok(_) => {
errors.push("SUBMIT_RATE_LIMIT_PER_SECOND must be greater than 0".to_string());
1
}
Err(_) => {
errors.push(format!(
"SUBMIT_RATE_LIMIT_PER_SECOND must be a valid u32, got '{}'",
submit_rate_limit_per_second_raw
));
1
}
};

let submit_rate_limit_burst: u32 = match submit_rate_limit_burst_raw.parse() {
Ok(v) if v > 0 => v,
Ok(_) => {
errors.push("SUBMIT_RATE_LIMIT_BURST must be greater than 0".to_string());
2
}
Err(_) => {
errors.push(format!(
"SUBMIT_RATE_LIMIT_BURST must be a valid u32, got '{}'",
submit_rate_limit_burst_raw
));
2
}
};

let stellar_max_retries: u32 = match stellar_max_retries_raw.parse() {
Ok(v) => v,
Err(_) => {
Expand Down Expand Up @@ -301,6 +351,8 @@ impl AppConfig {
redis_url,
rate_limit_per_second,
rate_limit_burst,
submit_rate_limit_per_second,
submit_rate_limit_burst,
stellar_max_retries,
log_level,
environment,
Expand Down Expand Up @@ -328,6 +380,8 @@ mod tests {
"REDIS_URL",
"RATE_LIMIT_PER_SECOND",
"RATE_LIMIT_BURST",
"SUBMIT_RATE_LIMIT_PER_SECOND",
"SUBMIT_RATE_LIMIT_BURST",
"STELLAR_MAX_RETRIES",
"LOG_LEVEL",
"APP_ENV",
Expand Down Expand Up @@ -358,6 +412,8 @@ mod tests {
);
assert_eq!(cfg.redis_url, "redis://127.0.0.1:6379");
assert_eq!(cfg.rate_limit_per_second, 10);
assert_eq!(cfg.submit_rate_limit_per_second, 1);
assert_eq!(cfg.submit_rate_limit_burst, 2);
assert_eq!(cfg.cache_verification_ttl, 3600);
assert_eq!(cfg.shutdown_timeout_secs, 30);
}
Expand All @@ -369,13 +425,15 @@ mod tests {
env::set_var("PORT", "0");
env::set_var("STELLAR_HORIZON_URL", "not-a-url");
env::set_var("RATE_LIMIT_PER_SECOND", "0");
env::set_var("SUBMIT_RATE_LIMIT_BURST", "0");

let err = AppConfig::from_env().expect_err("config should fail");
let msg = err.to_string();

assert!(msg.contains("PORT must be between 1 and 65535"));
assert!(msg.contains("STELLAR_HORIZON_URL must be a valid URL"));
assert!(msg.contains("RATE_LIMIT_PER_SECOND must be greater than 0"));
assert!(msg.contains("SUBMIT_RATE_LIMIT_BURST must be greater than 0"));
}

#[test]
Expand Down
1 change: 1 addition & 0 deletions contract/src/health.rs
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ mod tests {
metrics: Arc::new(MetricsRegistry::new()),
stellar_secret_key: String::new(),
rate_limiter: build_rate_limiter(1000, 1000),
submit_rate_limiter: build_rate_limiter(1000, 1000),
webhook_urls: Vec::new(),
webhook_secret: None,
}
Expand Down
68 changes: 12 additions & 56 deletions contract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,9 @@ pub mod webhook;
use axum::{
body::Body,
extract::{Path, State},
http::{HeaderName, Request, StatusCode},
http::{Request, StatusCode},
middleware::Next,
response::{IntoResponse, Response},
routing::{get, post},
Json, Router,
};
use chrono::{NaiveDate, Utc};
Expand All @@ -28,9 +27,6 @@ use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::sync::Arc;
use tower::ServiceBuilder;
use tower_http::request_id::{MakeRequestUuid, PropagateRequestIdLayer, SetRequestIdLayer};
use tower_http::trace::TraceLayer;
use tracing::{info, warn};

use cache::CacheBackend;
Expand All @@ -53,7 +49,13 @@ async fn enforce_rate_limit(
req: Request<Body>,
next: Next,
) -> Response {
if state.rate_limiter.check().is_err() {
let path = req.uri().path();
let limiter = if path == "/submit" || path == "/v1/submit" {
&state.submit_rate_limiter
} else {
&state.rate_limiter
};
if limiter.check().is_err() {
return (
StatusCode::TOO_MANY_REQUESTS,
Json(ValidationErrorResponse {
Expand All @@ -72,9 +74,10 @@ pub struct AppState {
pub cache: Arc<CacheBackend>,
pub metrics: Arc<MetricsRegistry>,
pub stellar_secret_key: String,
/// Governor-based rate limiter built from `RATE_LIMIT_PER_SECOND` /
/// `RATE_LIMIT_BURST` and enforced as a router middleware (CT-37).
/// Process-wide limiter for verification and other non-submit routes.
pub rate_limiter: rate_limit::DefaultRateLimiter,
/// Lower independent process-wide quota for document submissions.
pub submit_rate_limiter: rate_limit::DefaultRateLimiter,
/// Comma-separated webhook URLs parsed from `WEBHOOK_URLS` (CT-38).
pub webhook_urls: Vec<String>,
/// Shared secret used to sign webhook payloads (CT-38).
Expand Down Expand Up @@ -270,54 +273,7 @@ pub async fn audit_log_handler(State(state): State<AppState>) -> Response {
}

pub fn app(state: AppState) -> Router {
let request_id_header = HeaderName::from_static(REQUEST_ID_HEADER);
let span_header = request_id_header.clone();

Router::new()
.route("/health", get(health_check))
.route("/metrics", get(metrics_handler))
.route("/audit", get(audit_log_handler))
.route("/verify", post(verify_document))
.route("/verify/batch", post(batch_verify_documents))
.route("/verify/:hash", get(verify_document_by_hash))
.route("/verify/:hash/history", get(verify_document_history))
.route("/submit", post(submit_document))
.route("/revoke", post(revoke_document))
.route("/transfer", post(record_transfer))
.layer(
ServiceBuilder::new()
// Honour an inbound X-Request-Id (propagated from the NestJS
// backend, per BE-136); generate one only if it's absent.
.layer(SetRequestIdLayer::new(
request_id_header.clone(),
MakeRequestUuid,
))
.layer(
TraceLayer::new_for_http().make_span_with(move |request: &Request<_>| {
let request_id = request
.headers()
.get(&span_header)
.and_then(|v| v.to_str().ok())
.unwrap_or("unknown");

tracing::info_span!(
"http_request",
method = %request.method(),
path = %request.uri().path(),
request_id = %request_id,
)
}),
)
// Echo the request id back on the response so callers (and
// the backend) can confirm which id was used for this op.
.layer(PropagateRequestIdLayer::new(request_id_header)),
)
// Apply the configured rate limiter to the whole router (CT-37).
.layer(axum::middleware::from_fn_with_state(
state.clone(),
enforce_rate_limit,
))
.with_state(state)
routes::app(state)
}

// Health check endpoint
Expand Down
8 changes: 7 additions & 1 deletion contract/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,14 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {

// Startup configuration summary (redacting secrets)
info!(
"Configuration: port={}, stellar_horizon_url={}, redis_url={}, rate_limit_per_second={}, rate_limit_burst={}, stellar_max_retries={}, log_level={}, webhook_urls={:?}, stellar_secret_key=[REDACTED], webhook_secret=[REDACTED], cache_verification_ttl={}, shutdown_timeout_secs={}",
"Configuration: port={}, stellar_horizon_url={}, redis_url={}, rate_limit_per_second={}, rate_limit_burst={}, submit_rate_limit_per_second={}, submit_rate_limit_burst={}, stellar_max_retries={}, log_level={}, webhook_urls={:?}, stellar_secret_key=[REDACTED], webhook_secret=[REDACTED], cache_verification_ttl={}, shutdown_timeout_secs={}",
config.port,
config.stellar_horizon_url,
config.redis_url,
config.rate_limit_per_second,
config.rate_limit_burst,
config.submit_rate_limit_per_second,
config.submit_rate_limit_burst,
config.stellar_max_retries,
config.log_level,
config.webhook_urls,
Expand All @@ -80,6 +82,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
config.rate_limit_per_second,
config.rate_limit_burst,
),
submit_rate_limiter: build_rate_limiter(
config.submit_rate_limit_per_second,
config.submit_rate_limit_burst,
),
webhook_urls: config.webhook_urls.clone(),
webhook_secret: config.webhook_secret.clone(),
};
Expand Down
13 changes: 6 additions & 7 deletions contract/src/rate_limit.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
//! A `governor`-based rate limiter builder.
//!
//! **Not currently wired into the service.** [`build_rate_limiter`] is
//! defined but never called from `main.rs` or the Axum router - see the
//! "Known issues" section of `contract/README.md`. `RATE_LIMIT_PER_SECOND`
//! / `RATE_LIMIT_BURST` are parsed by [`crate::config::AppConfig`] but not
//! currently enforced anywhere.
//! Builds the independent process-wide quotas used by the Axum router.
//! Verification and other routes use `RATE_LIMIT_*`; `/submit` uses the
//! lower `SUBMIT_RATE_LIMIT_*` quota. These are global per service instance,
//! not per-client limits; the API currently has no authenticated client ID.

use governor::{Quota, RateLimiter};
use std::num::NonZeroU32;
Expand All @@ -15,8 +14,8 @@ pub type DefaultRateLimiter = RateLimiter<
governor::clock::DefaultClock,
>;

/// Build an in-memory, non-keyed rate limiter allowing `per_second`
/// sustained requests with a burst capacity of `burst`.
/// Build an in-memory rate limiter allowing `per_second` sustained requests
/// with a burst capacity of `burst`.
///
/// Panics if either argument is zero (`NonZeroU32::new(...).unwrap()`).
pub fn build_rate_limiter(per_second: u32, burst: u32) -> DefaultRateLimiter {
Expand Down
Loading