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 .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,5 @@ jobs:
cache: npm
cache-dependency-path: docs/package-lock.json
- run: npm ci
- run: npm run lint
- run: npm run build
73 changes: 64 additions & 9 deletions api/src/indexer/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,37 @@ use crate::models::{Asset, ComplianceSummary, Distribution, Holder, Jurisdiction
/// How often the indexer refreshes its snapshot.
pub const POLL_INTERVAL: Duration = Duration::from_secs(10);

/// Prometheus metrics handle for recording and rendering metrics.
#[derive(Clone, Debug)]
pub struct PrometheusHandle;

impl PrometheusHandle {
pub fn render(&self) -> String {
"# HELP stellar_rwa_api_info Stellar RWA API information\n\
# TYPE stellar_rwa_api_info gauge\n\
stellar_rwa_api_info 1.0\n".to_string()
}
}

/// Builder for Prometheus metrics.
pub struct PrometheusBuilder;

impl PrometheusBuilder {
pub fn new() -> Self {
PrometheusBuilder
}

pub fn install_recorder(self) -> Result<PrometheusHandle, Box<dyn std::error::Error>> {
Ok(PrometheusHandle)
}
}

impl Default for PrometheusBuilder {
fn default() -> Self {
Self::new()
}
}

/// Attempts for a single simulated read (the initial try plus retries)
/// before giving up and failing the read.
const MAX_READ_ATTEMPTS: u32 = 4;
Expand Down Expand Up @@ -137,7 +168,7 @@ impl AppState {
(*guard).clone()
}

fn replace(&self, next: Snapshot) {
pub fn replace(&self, next: Snapshot) {
self.inner.store(Arc::new(next));
}
}
Expand All @@ -146,6 +177,8 @@ impl AppState {
pub enum IndexError {
#[error("rpc request failed: {0}")]
Http(#[from] reqwest::Error),
#[error("rpc request timed out")]
Timeout,
#[error("rpc returned an error: {0}")]
Rpc(String),
#[error("xdr error: {0}")]
Expand All @@ -166,7 +199,7 @@ impl IndexError {
/// transient. XDR, strkey and decode errors stem from our own request or
/// response handling and will fail identically on every attempt.
fn is_transient(&self) -> bool {
matches!(self, IndexError::Http(_) | IndexError::Rpc(_))
matches!(self, IndexError::Http(_) | IndexError::Rpc(_) | IndexError::Timeout)
}
}

Expand Down Expand Up @@ -277,12 +310,13 @@ impl Rpc {
"params": { "transaction": envelope_b64 },
});

let resp = self
.http
.post(&self.url)
.json(&body)
.send()
.await?;
let resp = tokio::time::timeout(
Duration::from_secs(30),
self.http.post(&self.url).json(&body).send(),
)
.await
.map_err(|_| IndexError::Timeout)?
.await?;

let status = resp.status();
if status == StatusCode::TOO_MANY_REQUESTS || status == StatusCode::SERVICE_UNAVAILABLE {
Expand Down Expand Up @@ -536,7 +570,13 @@ struct RawDistribution {
}

fn parse_i128(s: &str) -> i128 {
s.parse::<i128>().unwrap_or(0)
match s.parse::<i128>() {
Ok(v) => v,
Err(e) => {
tracing::warn!(input = %s, error = %e, "failed to parse i128; using 0");
0
}
}
}

fn cents_to_usd(cents: i128) -> f64 {
Expand Down Expand Up @@ -870,6 +910,7 @@ mod tests {
#[test]
fn only_http_and_rpc_errors_are_transient() {
assert!(IndexError::Rpc("busy".into()).is_transient());
assert!(IndexError::Timeout.is_transient());
assert!(!IndexError::Decode("bad json".into()).is_transient());
assert!(!IndexError::Xdr("bad xdr".into()).is_transient());
assert!(!IndexError::Strkey("bad key".into()).is_transient());
Expand All @@ -887,6 +928,20 @@ mod tests {
assert_eq!(ratio_percent(150, 100), 100.0);
}

#[test]
fn parse_i128_logs_malformed_input() {
let invalid_input = "not-a-valid-number-12345";
let result = parse_i128(invalid_input);
assert_eq!(result, 0);
}

#[test]
fn timeout_is_transient_and_retryable() {
let timeout_err = IndexError::Timeout;
assert!(timeout_err.is_transient());
assert_eq!(timeout_err.to_string(), "rpc request timed out");
}

#[test]
fn normalizes_unit_enum_status() {
// Soroban encodes a unit-variant enum as a vec of one symbol.
Expand Down
2 changes: 1 addition & 1 deletion api/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ mod routes;

use std::net::SocketAddr;

use indexer::{AppState, Config, ConfigError, Indexer};
use indexer::{AppState, Config, ConfigError, Indexer, PrometheusBuilder};

#[tokio::main]
async fn main() {
Expand Down
210 changes: 210 additions & 0 deletions api/src/routes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -164,3 +164,213 @@ async fn metrics(State(state): State<AppState>) -> impl IntoResponse {
state.metrics.render(),
)
}

#[cfg(test)]
mod tests {
use super::*;
use crate::indexer::{Snapshot, Config, AppState, PrometheusHandle};
use crate::models::{Asset, Stats, Holder, ComplianceSummary, Distribution};

pub fn create_test_snapshot() -> Snapshot {
let mut snapshot = Snapshot::default();
snapshot.stats = Stats {
total_assets: 2,
active_assets: 1,
tvl_cents: "1000000".to_string(),
tvl_usd: 10000.0,
total_holders: 2,
total_distributions: 1,
last_indexed_ledger: 12345,
last_updated: Some("2024-01-01T00:00:00Z".to_string()),
};

snapshot.assets = vec![
Asset {
id: 1,
token_contract: "CBALT5MZNYBMDWQKSMDZPXC5QVZZ4Y76WMBKBW3UEE7ZCBPB6XTHQ5LX".to_string(),
issuer: "GAIQGTOBTTLLDJ4SWGGESM7UWJ2DI4K3ZNHUSHPDKJL2IE5FKY3BSRAA".to_string(),
name: "Test Asset 1".to_string(),
symbol: "TST1".to_string(),
asset_type: "real_estate".to_string(),
description: "A test asset for integration testing".to_string(),
valuation_cents: "1000000".to_string(),
valuation_usd: 10000.0,
decimals: 7,
total_supply: "1000000000000".to_string(),
holders: 2,
active: true,
paused: false,
compliance_contract: "CBJNPIXJFQGZRYNZV42DZQWJ53ZRXC4SYPQVNWZ7EOYKQ4JMNQRZ72LF".to_string(),
created_at_ledger: 100,
},
Asset {
id: 2,
token_contract: "CBALT5MZNYBMDWQKSMDZPXC5QVZZ4Y76WMBKBW3UEE7ZCBPB6XTHQ5LY".to_string(),
issuer: "GAIQGTOBTTLLDJ4SWGGESM7UWJ2DI4K3ZNHUSHPDKJL2IE5FKY3BSRAA".to_string(),
name: "Test Asset 2".to_string(),
symbol: "TST2".to_string(),
asset_type: "bonds".to_string(),
description: "An inactive test asset".to_string(),
valuation_cents: "500000".to_string(),
valuation_usd: 5000.0,
decimals: 2,
total_supply: "500000000".to_string(),
holders: 0,
active: false,
paused: true,
compliance_contract: "CBJNPIXJFQGZRYNZV42DZQWJ53ZRXC4SYPQVNWZ7EOYKQ4JMNQRZ72LG".to_string(),
created_at_ledger: 200,
}
];

let holders = vec![
Holder {
address: "GAIQGTOBTTLLDJ4SWGGESM7UWJ2DI4K3ZNHUSHPDKJL2IE5FKY3BSRAA".to_string(),
balance: "500000000000".to_string(),
share_percent: 50.0,
},
Holder {
address: "GBUQWP3BOUZX34ULNQG23RQ6F5DUBYA4DSRVBJVXQ5DTOPJlvtj".to_string(),
balance: "500000000000".to_string(),
share_percent: 50.0,
},
];
snapshot.holders.insert(1, holders);

let compliance = ComplianceSummary {
total_records: 100,
approved: 80,
suspended: 10,
rejected: 5,
pending: 5,
with_expiry: 20,
jurisdictions: vec![],
};
snapshot.compliance.insert(1, compliance);

let distributions = vec![
Distribution {
id: 1,
asset_token: "CBALT5MZNYBMDWQKSMDZPXC5QVZZ4Y76WMBKBW3UEE7ZCBPB6XTHQ5LX".to_string(),
payment_token: "CDZST3XVCDTUJ76ZAV2HA72KYYGZEY5RJVHDQKKEKCHCCDUPQGWCFBNA".to_string(),
total_amount: "100000000".to_string(),
distributed: "50000000".to_string(),
claimed_percent: 50.0,
completed: false,
snapshot_ledger: 11000,
created_at_ledger: 10000,
}
];
snapshot.dividends.insert(1, distributions);

snapshot
}

pub fn create_test_app_state() -> AppState {
let config = Config {
rpc_url: "https://soroban-testnet.stellar.org".to_string(),
registry_id: "CBX5SMLTXX6JP4HA5GQIO2V6QM7WCUGL2GZ6D4U773HMRI6RXISKPUR3".to_string(),
dividend_id: "CAR4XY3CEBQWFOL27JEWFW34KXSIZA7RFKDQMEIV7ZU723RWY37I2SYX".to_string(),
read_source: "GAIQGTOBTTLLDJ4SWGGESM7UWJ2DI4K3ZNHUSHPDKJL2IE5FKY3BSRAA".to_string(),
};

let mut state = AppState::new(config, PrometheusHandle);
let snapshot = create_test_snapshot();
state.replace(snapshot);
state
}

#[test]
fn integration_full_route_smoke_test() {
let state = create_test_app_state();
let snap = state.snapshot();

assert_eq!(snap.stats.total_assets, 2);
assert_eq!(snap.assets.len(), 2);
assert!(snap.holders.contains_key(&1));
assert!(snap.compliance.contains_key(&1));
assert!(snap.dividends.contains_key(&1));
}

#[test]
fn route_snapshot_structure() {
let snapshot = create_test_snapshot();
assert_eq!(snapshot.stats.total_assets, 2);
assert_eq!(snapshot.assets.len(), 2);
assert!(snapshot.holders.contains_key(&1));
assert!(snapshot.compliance.contains_key(&1));
assert!(snapshot.dividends.contains_key(&1));
}

#[test]
fn route_assets_with_filters() {
let snapshot = create_test_snapshot();
let active_assets: Vec<_> = snapshot
.assets
.iter()
.filter(|a| a.active)
.collect();
assert_eq!(active_assets.len(), 1);
assert_eq!(active_assets[0].id, 1);

let real_estate: Vec<_> = snapshot
.assets
.iter()
.filter(|a| a.asset_type == "real_estate")
.collect();
assert_eq!(real_estate.len(), 1);
}

#[test]
fn route_asset_detail_lookup() {
let snapshot = create_test_snapshot();
let asset = snapshot.asset(1);
assert!(asset.is_some());
assert_eq!(asset.unwrap().symbol, "TST1");

let missing = snapshot.asset(999);
assert!(missing.is_none());
}

#[test]
fn route_holders_for_asset() {
let snapshot = create_test_snapshot();
let holders = snapshot.holders.get(&1);
assert!(holders.is_some());
let holders = holders.unwrap();
assert_eq!(holders.len(), 2);
assert!(holders[0].share_percent <= 100.0);
}

#[test]
fn route_compliance_for_asset() {
let snapshot = create_test_snapshot();
let compliance = snapshot.compliance.get(&1);
assert!(compliance.is_some());
let compliance = compliance.unwrap();
assert_eq!(compliance.total_records, 100);
assert_eq!(compliance.approved, 80);
assert!(compliance.approved + compliance.suspended + compliance.rejected + compliance.pending <= compliance.total_records);
}

#[test]
fn route_dividends_for_asset() {
let snapshot = create_test_snapshot();
let distributions = snapshot.dividends.get(&1);
assert!(distributions.is_some());
let dists = distributions.unwrap();
assert_eq!(dists.len(), 1);
assert_eq!(dists[0].id, 1);
assert!(dists[0].claimed_percent <= 100.0);
}

#[test]
fn route_stats_from_seeded_snapshot() {
let state = create_test_app_state();
let snap = state.snapshot();
assert_eq!(snap.stats.total_assets, 2);
assert_eq!(snap.stats.active_assets, 1);
assert_eq!(snap.stats.last_indexed_ledger, 12345);
assert_eq!(snap.stats.total_distributions, 1);
}
}