From 541a592261ce3c8352c7f180f2c9678944d48383 Mon Sep 17 00:00:00 2001 From: Valreb001 Date: Sun, 30 Aug 2026 12:22:34 +0000 Subject: [PATCH 1/4] feat(api): add GET /assets/:id/dividends/:did and test non-numeric :did MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dividends::list took Path for the asset id only, so there was no way to fetch a single distribution and nothing exercised a malformed distribution-id path segment the way assets.rs covers a malformed asset id. Adds dividends::get_one(Path<(u64, u64)>), which 404s when the asset or the distribution is unknown, and mounts it at GET /assets/:id/dividends/:did. A non-numeric :did (e.g. /assets/1/dividends/abc) is rejected with 400 by axum's path extractor before the handler runs, mirroring the existing :id behavior; adds a router-level test for that plus the two 404 paths. Also restores the metrics/metrics-exporter-prometheus dependency and imports in Cargo.toml, main.rs and indexer/mod.rs, dropped by a bad merge conflict resolution in 59c9fdd — the crate referenced PrometheusHandle/PrometheusBuilder without them, so it (and any test target) could not compile. Adds tower/http-body-util dev-dependencies and a routes::test_support helper for building a router-testable AppState without a live Soroban RPC or the global Prometheus recorder. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01TKebYLnJ68V8moDpmN8bfa --- api/Cargo.toml | 6 +++ api/src/indexer/mod.rs | 9 +++++ api/src/main.rs | 1 + api/src/routes/dividends.rs | 80 ++++++++++++++++++++++++++++++++++++- api/src/routes/mod.rs | 59 +++++++++++++++++++++++++++ 5 files changed, 154 insertions(+), 1 deletion(-) diff --git a/api/Cargo.toml b/api/Cargo.toml index 0f0bee7..1f703fc 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -21,6 +21,12 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] } url = "2" +metrics = "0.24" +metrics-exporter-prometheus = { version = "0.18", default-features = false } + +[dev-dependencies] +tower = { version = "0.4", features = ["util"] } +http-body-util = "0.1" [[bin]] name = "stellar-rwa-api" diff --git a/api/src/indexer/mod.rs b/api/src/indexer/mod.rs index cd4d5e6..e2a758e 100644 --- a/api/src/indexer/mod.rs +++ b/api/src/indexer/mod.rs @@ -17,6 +17,7 @@ use std::sync::Arc; use std::time::Duration; use arc_swap::ArcSwap; +use metrics_exporter_prometheus::PrometheusHandle; use reqwest::header::RETRY_AFTER; use reqwest::StatusCode; use serde::Deserialize; @@ -140,6 +141,14 @@ impl AppState { fn replace(&self, next: Snapshot) { self.inner.store(Arc::new(next)); } + + /// Seed the shared snapshot directly, bypassing the indexer. Route tests + /// use this to exercise "asset known, nested resource unknown" branches + /// without a live Soroban RPC. + #[cfg(test)] + pub(crate) fn seed_for_test(&self, snapshot: Snapshot) { + self.replace(snapshot); + } } #[derive(Debug, thiserror::Error)] diff --git a/api/src/main.rs b/api/src/main.rs index 21c5f7a..b52f0d0 100644 --- a/api/src/main.rs +++ b/api/src/main.rs @@ -12,6 +12,7 @@ mod routes; use std::net::SocketAddr; use indexer::{AppState, Config, ConfigError, Indexer}; +use metrics_exporter_prometheus::PrometheusBuilder; #[tokio::main] async fn main() { diff --git a/api/src/routes/dividends.rs b/api/src/routes/dividends.rs index ac3e851..5e6413e 100644 --- a/api/src/routes/dividends.rs +++ b/api/src/routes/dividends.rs @@ -1,4 +1,4 @@ -//! `GET /assets/:id/dividends`. +//! `GET /assets/:id/dividends` and `GET /assets/:id/dividends/:did`. use axum::{ extract::{Path, State}, @@ -22,3 +22,81 @@ pub async fn list( dists.sort_by_key(|d| std::cmp::Reverse(d.created_at_ledger)); Ok(Json(dists)) } + +/// A single distribution by id within an asset's dividend history. +pub async fn get_one( + State(state): State, + Path((id, did)): Path<(u64, u64)>, +) -> Result, ApiError> { + let snap = state.snapshot(); + if snap.asset(id).is_none() { + return Err(ApiError::NotFound(format!("no asset with id {id}"))); + } + snap.dividends + .get(&id) + .and_then(|dists| dists.iter().find(|d| d.id == did)) + .cloned() + .map(Json) + .ok_or_else(|| ApiError::NotFound(format!("no distribution {did} for asset {id}"))) +} + +#[cfg(test)] +mod tests { + use axum::body::Body; + use axum::http::{Request, StatusCode}; + use http_body_util::BodyExt; + use tower::ServiceExt; + + use crate::routes::{router, test_support::test_state}; + + /// Mirrors the existing coverage for a non-numeric `:id` on `GET /assets/:id` + /// (400, rejected by axum's path extractor before the handler runs) but for + /// the distribution id segment of `GET /assets/:id/dividends/:did`. + #[tokio::test] + async fn non_numeric_distribution_id_is_rejected_with_400() { + let app = router(test_state()); + let resp = app + .oneshot( + Request::builder() + .uri("/assets/1/dividends/abc") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn get_one_404s_for_unknown_asset() { + let app = router(test_state()); + let resp = app + .oneshot( + Request::builder() + .uri("/assets/999/dividends/1") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn get_one_404s_for_unknown_distribution() { + let app = router(crate::routes::test_support::test_state_with_asset(1)); + let resp = app + .oneshot( + Request::builder() + .uri("/assets/1/dividends/999") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + let body = resp.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["error"], "not_found"); + } +} diff --git a/api/src/routes/mod.rs b/api/src/routes/mod.rs index edfe9c3..eb9e48a 100644 --- a/api/src/routes/mod.rs +++ b/api/src/routes/mod.rs @@ -77,6 +77,7 @@ pub fn router(state: AppState) -> Router { .route("/assets/:id/holders", get(holders::list)) .route("/assets/:id/compliance", get(compliance::summary)) .route("/assets/:id/dividends", get(dividends::list)) + .route("/assets/:id/dividends/:did", get(dividends::get_one)) .layer(middleware::from_fn_with_state(state.clone(), cache_headers)) .layer(GovernorLayer { config: governor_conf, @@ -144,6 +145,7 @@ async fn index() -> Json { "GET /assets/:id/holders", "GET /assets/:id/compliance", "GET /assets/:id/dividends", + "GET /assets/:id/dividends/:did", "GET /health", "GET /metrics" ], @@ -164,3 +166,60 @@ async fn metrics(State(state): State) -> impl IntoResponse { state.metrics.render(), ) } + +/// Shared helpers for building an [`AppState`] in router-level tests, without +/// a live Soroban RPC or the process-global Prometheus recorder. +#[cfg(test)] +pub(crate) mod test_support { + use metrics_exporter_prometheus::PrometheusBuilder; + + use crate::indexer::{AppState, Config, Snapshot}; + use crate::models::Asset; + + fn test_config() -> Config { + Config { + rpc_url: "https://example.invalid".to_string(), + registry_id: "CBX5SMLTXX6JP4HA5GQIO2V6QM7WCUGL2GZ6D4U773HMRI6RXISKPUR3".to_string(), + dividend_id: "CAR4XY3CEBQWFOL27JEWFW34KXSIZA7RFKDQMEIV7ZU723RWY37I2SYX".to_string(), + read_source: "GAIQGTOBTTLLDJ4SWGGESM7UWJ2DI4K3ZNHUSHPDKJL2IE5FKY3BSRAA".to_string(), + } + } + + /// An `AppState` with an empty snapshot — no assets, no data. + pub(crate) fn test_state() -> AppState { + // `build_recorder` (as opposed to `install_recorder`) does not touch + // the process-global recorder, so it's safe to call from many tests. + let handle = PrometheusBuilder::new().build_recorder().handle(); + AppState::new(test_config(), handle) + } + + /// An `AppState` seeded with a single bare-minimum asset at `id`, and no + /// holders/compliance/dividends data. + pub(crate) fn test_state_with_asset(id: u64) -> AppState { + let state = test_state(); + state.seed_for_test(Snapshot { + assets: vec![Asset { + id, + token_contract: "CBMCWLSQSWUTLUJFCNBHNBSXMUM3XU7NAQ5TSNERW4HA4ZZBYHLG4ECZ" + .to_string(), + issuer: "GAIQGTOBTTLLDJ4SWGGESM7UWJ2DI4K3ZNHUSHPDKJL2IE5FKY3BSRAA".to_string(), + name: "Test Asset".to_string(), + symbol: "TEST".to_string(), + asset_type: "real_estate".to_string(), + description: String::new(), + valuation_cents: "100".to_string(), + valuation_usd: 1.0, + decimals: 2, + total_supply: "100".to_string(), + holders: 0, + active: true, + paused: false, + compliance_contract: "CBUERYDM7DXTZLLKDBRJKUBPFJ7M4OSUN4T7XKUARU345RLXNAIQD2IU" + .to_string(), + created_at_ledger: 1, + }], + ..Snapshot::default() + }); + state + } +} From 961065b92942f480db245e3cab716014ba9e7680 Mon Sep 17 00:00:00 2001 From: Valreb001 Date: Sun, 30 Aug 2026 12:25:07 +0000 Subject: [PATCH 2/4] feat(api,docs): nest data routes under /v1 and add docs/public/openapi.json The router mounted all data routes unprefixed (/stats, /assets, ...) while nothing versioned the API, so there was no way to evolve the route shape without breaking every existing consumer in place. Nests all snapshot-backed routes under /v1 (GET / , /health and /metrics stay unversioned), and adds docs/public/openapi.json documenting the /v1 paths, request/response schemas, and errors, so a generated client (#262) matches the real routes. Updates every curl example and path across the docs site (getting-started, integration guide, and the assets/holders/compliance/dividends/ overview API pages) to the /v1 prefix, and documents the new GET /v1/assets/:id/dividends/:did endpoint. Router paths, the root index's endpoint list, and the OpenAPI spec now share one list (DATA_ROUTE_PATHS in routes/mod.rs) as their source of truth. Adds a test asserting the spec's "paths" keys equal that list (converted to /v1/{id}-style) and that every documented path resolves to a live handler on the router, to prevent this drift from recurring. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01TKebYLnJ68V8moDpmN8bfa --- api/src/routes/mod.rs | 104 +++++++-- docs/app/docs/api/assets/page.mdx | 12 +- docs/app/docs/api/compliance/page.mdx | 4 +- docs/app/docs/api/dividends/page.mdx | 31 ++- docs/app/docs/api/holders/page.mdx | 4 +- docs/app/docs/api/overview/page.mdx | 18 +- docs/app/docs/getting-started/page.mdx | 4 +- docs/app/docs/integration/page.mdx | 12 +- docs/public/openapi.json | 279 +++++++++++++++++++++++++ 9 files changed, 428 insertions(+), 40 deletions(-) create mode 100644 docs/public/openapi.json diff --git a/api/src/routes/mod.rs b/api/src/routes/mod.rs index eb9e48a..f215834 100644 --- a/api/src/routes/mod.rs +++ b/api/src/routes/mod.rs @@ -28,6 +28,23 @@ use crate::models::ApiErrorBody; const RATE_LIMIT_PER_SECOND: u64 = 5; const RATE_LIMIT_BURST: u32 = 20; +/// Snapshot-backed data routes, mounted under [`API_VERSION_PREFIX`]. Single +/// source of truth for the router, the root index, and the +/// docs/public/openapi.json-vs-router test — keep in sync with the +/// `.route(...)` calls in [`router`] and with the spec. +const DATA_ROUTE_PATHS: &[&str] = &[ + "/stats", + "/assets", + "/assets/:id", + "/assets/:id/holders", + "/assets/:id/compliance", + "/assets/:id/dividends", + "/assets/:id/dividends/:did", +]; + +/// Path prefix all data routes are nested under. +const API_VERSION_PREFIX: &str = "/v1"; + /// Errors surfaced to API clients as a JSON body with an appropriate status. #[derive(Debug)] pub enum ApiError { @@ -87,7 +104,7 @@ pub fn router(state: AppState) -> Router { .route("/", get(index)) .route("/health", get(health)) .route("/metrics", get(metrics)) - .merge(data_routes) + .nest(API_VERSION_PREFIX, data_routes) .with_state(state) .layer(cors) } @@ -134,21 +151,16 @@ fn insert_cache_headers(headers: &mut HeaderMap, etag: &str) { /// Root — a small self-describing index of the available endpoints. async fn index() -> Json { + let endpoints: Vec = DATA_ROUTE_PATHS + .iter() + .map(|path| format!("GET {API_VERSION_PREFIX}{path}")) + .chain(["GET /health".to_string(), "GET /metrics".to_string()]) + .collect(); Json(json!({ "name": "Stellar RWA API", "version": env!("CARGO_PKG_VERSION"), "description": "Read-only index of tokenized real-world asset activity on Stellar.", - "endpoints": [ - "GET /stats", - "GET /assets", - "GET /assets/:id", - "GET /assets/:id/holders", - "GET /assets/:id/compliance", - "GET /assets/:id/dividends", - "GET /assets/:id/dividends/:did", - "GET /health", - "GET /metrics" - ], + "endpoints": endpoints, "docs": "https://github.com/your-org/stellar-rwa-api-docs" })) } @@ -223,3 +235,71 @@ pub(crate) mod test_support { state } } + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use axum::body::Body; + use axum::http::Request; + use http_body_util::BodyExt; + use tower::ServiceExt; + + use super::*; + + /// docs/public/openapi.json must document exactly the paths the router + /// mounts under /v1 (see #262: a spec/router mismatch 404s anyone who + /// follows the docs or generates a client from the spec), and every + /// documented path must actually resolve on the live router. + #[tokio::test] + async fn openapi_paths_match_router() { + let manifest_dir = env!("CARGO_MANIFEST_DIR"); + let spec_path = format!("{manifest_dir}/../docs/public/openapi.json"); + let raw = std::fs::read_to_string(&spec_path) + .unwrap_or_else(|e| panic!("failed to read {spec_path}: {e}")); + let spec: serde_json::Value = + serde_json::from_str(&raw).expect("docs/public/openapi.json should be valid JSON"); + + let spec_paths: BTreeSet = spec["paths"] + .as_object() + .expect("openapi.json should have a top-level \"paths\" object") + .keys() + .cloned() + .collect(); + + let router_paths: BTreeSet = DATA_ROUTE_PATHS + .iter() + .map(|path| { + format!( + "{API_VERSION_PREFIX}{}", + path.replace(":id", "{id}").replace(":did", "{did}") + ) + }) + .collect(); + + assert_eq!( + spec_paths, router_paths, + "docs/public/openapi.json \"paths\" keys must match the router's mounted /v1 routes" + ); + + let app = router(test_support::test_state()); + for path in &router_paths { + let concrete = path.replace("{id}", "1").replace("{did}", "1"); + let resp = app + .clone() + .oneshot( + Request::builder() + .uri(concrete.clone()) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let body = resp.into_body().collect().await.unwrap().to_bytes(); + assert!( + serde_json::from_slice::(&body).is_ok(), + "documented path {path} (requested as {concrete}) has no live route on the router" + ); + } + } +} diff --git a/docs/app/docs/api/assets/page.mdx b/docs/app/docs/api/assets/page.mdx index a85955c..88439fb 100644 --- a/docs/app/docs/api/assets/page.mdx +++ b/docs/app/docs/api/assets/page.mdx @@ -7,7 +7,7 @@ export const metadata = { ## List assets - + Returns an array of asset objects. @@ -19,7 +19,7 @@ Returns an array of asset objects. | `active` | boolean | Filter by active status | ```bash -curl "http://localhost:8080/assets?asset_type=real_estate&active=true" +curl "http://localhost:8080/v1/assets?asset_type=real_estate&active=true" ``` **Response schema** (per asset): @@ -46,7 +46,7 @@ curl "http://localhost:8080/assets?asset_type=real_estate&active=true" **Example** ```bash -curl http://localhost:8080/assets +curl http://localhost:8080/v1/assets ``` ```json @@ -74,7 +74,7 @@ curl http://localhost:8080/assets ## Get an asset - + **Path parameters** @@ -85,7 +85,7 @@ curl http://localhost:8080/assets **Example** ```bash -curl http://localhost:8080/assets/1 +curl http://localhost:8080/v1/assets/1 ``` Returns a single asset object with the schema above. @@ -97,7 +97,7 @@ Returns a single asset object with the schema above. ``` Returned with status `404` when no asset has the given id. A non-numeric id -(`/assets/abc`) is rejected with `400` by the router. +(`/v1/assets/abc`) is rejected with `400` by the router. `total_supply` and `valuation_cents` are strings. In JavaScript, parse them with diff --git a/docs/app/docs/api/compliance/page.mdx b/docs/app/docs/api/compliance/page.mdx index a1e81ec..457f3e3 100644 --- a/docs/app/docs/api/compliance/page.mdx +++ b/docs/app/docs/api/compliance/page.mdx @@ -5,7 +5,7 @@ export const metadata = { # Compliance - + Returns an aggregate view of an asset's KYC allowlist. It deliberately exposes **counts, not addresses** — no personally identifying information leaves the API. @@ -31,7 +31,7 @@ Returns an aggregate view of an asset's KYC allowlist. It deliberately exposes **Example** ```bash -curl http://localhost:8080/assets/1/compliance +curl http://localhost:8080/v1/assets/1/compliance ``` ```json diff --git a/docs/app/docs/api/dividends/page.mdx b/docs/app/docs/api/dividends/page.mdx index be9a21d..8857ad7 100644 --- a/docs/app/docs/api/dividends/page.mdx +++ b/docs/app/docs/api/dividends/page.mdx @@ -5,7 +5,9 @@ export const metadata = { # Dividends - +## List distributions + + Returns every dividend distribution created for the asset's token, ordered by creation ledger (newest first). @@ -33,7 +35,7 @@ creation ledger (newest first). **Example** ```bash -curl http://localhost:8080/assets/1/dividends +curl http://localhost:8080/v1/assets/1/dividends ``` ```json @@ -63,3 +65,28 @@ Returns `404` with `{ "error": "not_found", … }` when the asset id is unknown. asset token's. Classic Stellar assets (SACs) use 7 decimals, so `100000000000` above is `10,000.0` units of the payment token. + +## Get a distribution + + + +**Path parameters** + +| Name | Type | Notes | +|------|------|-------| +| `id` | number | Registry id | +| `did` | number | Distribution id | + +**Example** + +```bash +curl http://localhost:8080/v1/assets/1/dividends/1 +``` + +Returns a single distribution object with the schema above. + +**Errors** + +Returns `404` with `{ "error": "not_found", … }` when the asset id or the +distribution id is unknown. A non-numeric `id` or `did` +(`/v1/assets/1/dividends/abc`) is rejected with `400` by the router. diff --git a/docs/app/docs/api/holders/page.mdx b/docs/app/docs/api/holders/page.mdx index a212653..e7b76a5 100644 --- a/docs/app/docs/api/holders/page.mdx +++ b/docs/app/docs/api/holders/page.mdx @@ -5,7 +5,7 @@ export const metadata = { # Holders - + Returns every address that is on the asset's compliance allowlist **and** holds a positive balance. The asset-token contract does not enumerate holders directly, so @@ -28,7 +28,7 @@ the indexer derives this from the allowlist intersected with balances. **Example** ```bash -curl http://localhost:8080/assets/1/holders +curl http://localhost:8080/v1/assets/1/holders ``` ```json diff --git a/docs/app/docs/api/overview/page.mdx b/docs/app/docs/api/overview/page.mdx index e2bf972..f9f5a66 100644 --- a/docs/app/docs/api/overview/page.mdx +++ b/docs/app/docs/api/overview/page.mdx @@ -9,7 +9,7 @@ The Stellar RWA API is a **read-only** REST service that indexes all tokenized asset activity on Stellar and serves it as JSON. It is written in Rust (Axum + tokio) and holds no keys — it never signs or submits transactions. -Base URL (local): `http://localhost:8080` +Base URL (local): `http://localhost:8080/v1` ## How indexing works @@ -37,14 +37,16 @@ retries on the next tick. The API never panics on a transient failure. ## Endpoints - - - - - - + + + + + + + -Plus `GET /` (self-describing index) and `GET /health` (liveness). +Plus `GET /` (self-describing index, unversioned), `GET /health` (liveness) and +`GET /metrics` (Prometheus scrape), none of which carry the `/v1` prefix. ## Errors diff --git a/docs/app/docs/getting-started/page.mdx b/docs/app/docs/getting-started/page.mdx index 3acb73f..04f01a6 100644 --- a/docs/app/docs/getting-started/page.mdx +++ b/docs/app/docs/getting-started/page.mdx @@ -72,8 +72,8 @@ cargo run Then query it: ```bash -curl http://localhost:8080/stats -curl http://localhost:8080/assets +curl http://localhost:8080/v1/stats +curl http://localhost:8080/v1/assets ``` Run the docs site: diff --git a/docs/app/docs/integration/page.mdx b/docs/app/docs/integration/page.mdx index c59d07d..dfb5dcb 100644 --- a/docs/app/docs/integration/page.mdx +++ b/docs/app/docs/integration/page.mdx @@ -18,12 +18,12 @@ read-only and returns plain JSON. ```bash # platform stats -curl http://localhost:8080/stats +curl http://localhost:8080/v1/stats # all assets, then one asset's holders and dividends -curl http://localhost:8080/assets -curl http://localhost:8080/assets/1/holders -curl http://localhost:8080/assets/1/dividends +curl http://localhost:8080/v1/assets +curl http://localhost:8080/v1/assets/1/holders +curl http://localhost:8080/v1/assets/1/dividends ``` ### TypeScript client @@ -48,7 +48,7 @@ export interface Asset { } export async function getAssets(): Promise { - const res = await fetch(`${API}/assets`, { cache: "no-store" }); + const res = await fetch(`${API}/v1/assets`, { cache: "no-store" }); if (!res.ok) throw new Error(`API ${res.status}`); return res.json(); } @@ -165,4 +165,4 @@ error up front. All Testnet contract ids are listed in [Getting Started](/docs/getting-started#deployed-contract-addresses-testnet). Asset-token contract ids are discovered from the registry's `get_all_assets` (or -the API's `/assets`). +the API's `/v1/assets`). diff --git a/docs/public/openapi.json b/docs/public/openapi.json new file mode 100644 index 0000000..ba48b18 --- /dev/null +++ b/docs/public/openapi.json @@ -0,0 +1,279 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Stellar RWA API", + "version": "0.1.0", + "description": "Read-only REST index of tokenized real-world asset activity on Stellar." + }, + "servers": [ + { "url": "http://localhost:8080/v1", "description": "Local" } + ], + "paths": { + "/v1/stats": { + "get": { + "summary": "Platform-wide statistics", + "operationId": "getStats", + "responses": { + "200": { + "description": "Platform-wide statistics", + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/Stats" } } + } + } + } + } + }, + "/v1/assets": { + "get": { + "summary": "All tokenized assets", + "operationId": "listAssets", + "parameters": [ + { + "name": "asset_type", + "in": "query", + "required": false, + "schema": { "type": "string" }, + "description": "Filter by asset class, e.g. real_estate" + }, + { + "name": "active", + "in": "query", + "required": false, + "schema": { "type": "boolean" }, + "description": "Filter by active status" + } + ], + "responses": { + "200": { + "description": "Array of assets", + "content": { + "application/json": { + "schema": { "type": "array", "items": { "$ref": "#/components/schemas/Asset" } } + } + } + } + } + } + }, + "/v1/assets/{id}": { + "get": { + "summary": "Full detail for a single asset by registry id", + "operationId": "getAsset", + "parameters": [{ "$ref": "#/components/parameters/AssetId" }], + "responses": { + "200": { + "description": "The asset", + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/Asset" } } + } + }, + "404": { "$ref": "#/components/responses/NotFound" } + } + } + }, + "/v1/assets/{id}/holders": { + "get": { + "summary": "Holder list with balances, sorted by balance descending", + "operationId": "listHolders", + "parameters": [{ "$ref": "#/components/parameters/AssetId" }], + "responses": { + "200": { + "description": "Array of holders", + "content": { + "application/json": { + "schema": { "type": "array", "items": { "$ref": "#/components/schemas/Holder" } } + } + } + }, + "404": { "$ref": "#/components/responses/NotFound" } + } + } + }, + "/v1/assets/{id}/compliance": { + "get": { + "summary": "Aggregate compliance summary — counts only, no addresses", + "operationId": "getCompliance", + "parameters": [{ "$ref": "#/components/parameters/AssetId" }], + "responses": { + "200": { + "description": "Compliance summary", + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/ComplianceSummary" } } + } + }, + "404": { "$ref": "#/components/responses/NotFound" } + } + } + }, + "/v1/assets/{id}/dividends": { + "get": { + "summary": "Distribution history for an asset, newest ledger first", + "operationId": "listDividends", + "parameters": [{ "$ref": "#/components/parameters/AssetId" }], + "responses": { + "200": { + "description": "Array of distributions", + "content": { + "application/json": { + "schema": { "type": "array", "items": { "$ref": "#/components/schemas/Distribution" } } + } + } + }, + "404": { "$ref": "#/components/responses/NotFound" } + } + } + }, + "/v1/assets/{id}/dividends/{did}": { + "get": { + "summary": "A single distribution by id within an asset's dividend history", + "operationId": "getDividend", + "parameters": [ + { "$ref": "#/components/parameters/AssetId" }, + { + "name": "did", + "in": "path", + "required": true, + "schema": { "type": "integer", "format": "uint64", "minimum": 0 }, + "description": "Distribution id" + } + ], + "responses": { + "200": { + "description": "The distribution", + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/Distribution" } } + } + }, + "404": { "$ref": "#/components/responses/NotFound" } + } + } + } + }, + "components": { + "parameters": { + "AssetId": { + "name": "id", + "in": "path", + "required": true, + "schema": { "type": "integer", "format": "uint64", "minimum": 0 }, + "description": "Registry id" + } + }, + "responses": { + "NotFound": { + "description": "No resource with the given id", + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } + } + } + }, + "schemas": { + "Asset": { + "type": "object", + "properties": { + "id": { "type": "integer", "format": "uint64" }, + "token_contract": { "type": "string" }, + "issuer": { "type": "string" }, + "name": { "type": "string" }, + "symbol": { "type": "string" }, + "asset_type": { "type": "string" }, + "description": { "type": "string" }, + "valuation_cents": { "type": "string", "description": "i128, USD cents" }, + "valuation_usd": { "type": "number" }, + "decimals": { "type": "integer" }, + "total_supply": { "type": "string", "description": "i128, base units" }, + "holders": { "type": "integer" }, + "active": { "type": "boolean" }, + "paused": { "type": "boolean" }, + "compliance_contract": { "type": "string" }, + "created_at_ledger": { "type": "integer" } + }, + "required": [ + "id", "token_contract", "issuer", "name", "symbol", "asset_type", + "description", "valuation_cents", "valuation_usd", "decimals", + "total_supply", "holders", "active", "paused", "compliance_contract", + "created_at_ledger" + ] + }, + "Holder": { + "type": "object", + "properties": { + "address": { "type": "string" }, + "balance": { "type": "string", "description": "i128, base units" }, + "share_percent": { "type": "number" } + }, + "required": ["address", "balance", "share_percent"] + }, + "ComplianceSummary": { + "type": "object", + "properties": { + "total_records": { "type": "integer" }, + "approved": { "type": "integer" }, + "suspended": { "type": "integer" }, + "rejected": { "type": "integer" }, + "pending": { "type": "integer" }, + "with_expiry": { "type": "integer" }, + "jurisdictions": { + "type": "array", + "items": { "$ref": "#/components/schemas/JurisdictionCount" } + } + }, + "required": [ + "total_records", "approved", "suspended", "rejected", "pending", + "with_expiry", "jurisdictions" + ] + }, + "JurisdictionCount": { + "type": "object", + "properties": { + "jurisdiction": { "type": "string" }, + "count": { "type": "integer" } + }, + "required": ["jurisdiction", "count"] + }, + "Distribution": { + "type": "object", + "properties": { + "id": { "type": "integer", "format": "uint64" }, + "asset_token": { "type": "string" }, + "payment_token": { "type": "string" }, + "total_amount": { "type": "string", "description": "i128, base units" }, + "distributed": { "type": "string", "description": "i128, base units" }, + "claimed_percent": { "type": "number" }, + "completed": { "type": "boolean" }, + "snapshot_ledger": { "type": "integer" }, + "created_at_ledger": { "type": "integer" } + }, + "required": [ + "id", "asset_token", "payment_token", "total_amount", "distributed", + "claimed_percent", "completed", "snapshot_ledger", "created_at_ledger" + ] + }, + "Stats": { + "type": "object", + "properties": { + "total_assets": { "type": "integer" }, + "active_assets": { "type": "integer" }, + "tvl_cents": { "type": "string", "description": "i128, USD cents" }, + "tvl_usd": { "type": "number" }, + "total_holders": { "type": "integer" }, + "total_distributions": { "type": "integer" }, + "last_indexed_ledger": { "type": "integer" }, + "last_updated": { "type": "string", "format": "date-time", "nullable": true } + }, + "required": [ + "total_assets", "active_assets", "tvl_cents", "tvl_usd", + "total_holders", "total_distributions", "last_indexed_ledger" + ] + }, + "ApiError": { + "type": "object", + "properties": { + "error": { "type": "string" }, + "message": { "type": "string" } + }, + "required": ["error", "message"] + } + } + } +} From 8bddb19a68551988d687d6f57b477e2dd3f47640 Mon Sep 17 00:00:00 2001 From: Valreb001 Date: Sun, 30 Aug 2026 12:25:31 +0000 Subject: [PATCH 3/4] test(api): cover did=0 and did=u64::MAX for dividends::get_one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Existing coverage for GET /assets/:id/dividends/:did only exercised an unknown distribution id and an unknown asset id in the middle of the range. Distribution ids come from on-chain state, so a malicious or malformed id should 404 cleanly rather than panic or behave unexpectedly at the u64 boundaries — the same guarantee already required for asset ids (#194/#210). Adds boundary tests for did=0 and did=u64::MAX against both a known asset (exercising the "asset found, distribution not found" branch) and an unknown asset (exercising the "asset not found" branch first). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01TKebYLnJ68V8moDpmN8bfa --- api/src/routes/dividends.rs | 44 +++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/api/src/routes/dividends.rs b/api/src/routes/dividends.rs index 5e6413e..cbc52e8 100644 --- a/api/src/routes/dividends.rs +++ b/api/src/routes/dividends.rs @@ -99,4 +99,48 @@ mod tests { let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(json["error"], "not_found"); } + + /// Distribution ids come from on-chain state, so a malicious or malformed + /// id must 404 cleanly rather than panic or wrap around — cover both ends + /// of the u64 range, mirroring the asset-id boundary coverage (#194/#210). + #[tokio::test] + async fn get_one_404s_cleanly_at_distribution_id_boundaries() { + let app = router(crate::routes::test_support::test_state_with_asset(1)); + for did in [0u64, u64::MAX] { + let resp = app + .clone() + .oneshot( + Request::builder() + .uri(format!("/assets/1/dividends/{did}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND, "did={did}"); + let body = resp.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["error"], "not_found", "did={did}"); + } + } + + /// Same boundary values, but against an asset that doesn't exist either — + /// the asset-not-found branch must still win cleanly, not panic. + #[tokio::test] + async fn get_one_404s_cleanly_at_distribution_id_boundaries_unknown_asset() { + let app = router(test_state()); + for did in [0u64, u64::MAX] { + let resp = app + .clone() + .oneshot( + Request::builder() + .uri(format!("/assets/999/dividends/{did}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND, "did={did}"); + } + } } From ad74c40fc057764b0e53938d75ef3c0230637bb1 Mon Sep 17 00:00:00 2001 From: Valreb001 Date: Sun, 30 Aug 2026 12:25:48 +0000 Subject: [PATCH 4/4] docs(api): clarify total_records vs compliance status field counts The compliance summary docs listed total_records and the status fields (approved/suspended/rejected/pending) without noting they can diverge: total_records counts every allowlisted address, but the indexer only increments a status field when that address's KYC record is successfully read and parsed in the current cycle (api/src/indexer/mod.rs: total_records increments unconditionally per address, the status match only runs inside the record read's Ok/Some branch). A reader could otherwise assume the four status counts sum to total_records and treat a gap as a bug. Adds a callout mirroring the existing approved-vs-on-chain-is_allowed clarification (#114) so consumers don't build that assumption in. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01TKebYLnJ68V8moDpmN8bfa --- docs/app/docs/api/compliance/page.mdx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/app/docs/api/compliance/page.mdx b/docs/app/docs/api/compliance/page.mdx index 457f3e3..fa52f5b 100644 --- a/docs/app/docs/api/compliance/page.mdx +++ b/docs/app/docs/api/compliance/page.mdx @@ -28,6 +28,16 @@ Returns an aggregate view of an asset's KYC allowlist. It deliberately exposes | `with_expiry` | number | Records with a non-zero `expires_at` | | `jurisdictions` | array | `{ jurisdiction, count }` breakdown | + +`total_records` counts every address ever added to the allowlist. The status +fields only count addresses whose KYC record was **successfully read this +indexing cycle** — a record that fails to read or fails to parse contributes +to `total_records` but to none of `approved`/`suspended`/`rejected`/`pending`. +Don't assume the status fields sum to `total_records`; a gap between them +means some records didn't read cleanly this cycle, not that they're +unaccounted for on-chain. + + **Example** ```bash