diff --git a/api/Cargo.toml b/api/Cargo.toml index 5762e7d..77291fd 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -25,6 +25,12 @@ metrics = "0.24" metrics-exporter-prometheus = "0.16" rand = "0.9" 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" [dev-dependencies] insta = { version = "1", features = ["json"] } diff --git a/api/src/indexer/mod.rs b/api/src/indexer/mod.rs index 6090405..4a14e7a 100644 --- a/api/src/indexer/mod.rs +++ b/api/src/indexer/mod.rs @@ -216,6 +216,12 @@ impl AppState { 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); /// Test-only: build state pre-populated with `snapshot`. #[cfg(test)] pub(crate) fn for_test(config: Config, metrics: PrometheusHandle, snapshot: Snapshot) -> Self { diff --git a/api/src/main.rs b/api/src/main.rs index 793a3da..e2b0ab9 100644 --- a/api/src/main.rs +++ b/api/src/main.rs @@ -11,6 +11,8 @@ mod routes; use std::net::SocketAddr; +use indexer::{AppState, Config, ConfigError, Indexer}; +use metrics_exporter_prometheus::PrometheusBuilder; use indexer::{AppState, Config, Indexer}; use metrics_exporter_prometheus::PrometheusBuilder; use tokio::sync::watch; diff --git a/api/src/routes/dividends.rs b/api/src/routes/dividends.rs index 3a78a31..853b55c 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}, @@ -23,6 +23,7 @@ pub async fn list( Ok(Json(dists)) } +/// A single distribution by id within an asset's dividend history. /// `GET /assets/:id/distributions/:did` — a single distribution by id. pub async fn get_one( State(state): State, @@ -32,6 +33,12 @@ pub async fn get_one( 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}"))) let dist = snap .dividends .get(&id) @@ -45,6 +52,105 @@ pub async fn get_one( #[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"); + } + + /// 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}"); use axum::extract::{Path, State}; use super::{get_one, list}; diff --git a/api/src/routes/mod.rs b/api/src/routes/mod.rs index b2cc085..8d65162 100644 --- a/api/src/routes/mod.rs +++ b/api/src/routes/mod.rs @@ -41,6 +41,23 @@ fn env_value(name: &str, default: T) -> T { .unwrap_or(default) } +/// 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 { @@ -102,6 +119,11 @@ 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, + }); .route("/assets/:id/distributions/:did", get(dividends::get_one)) .layer(middleware::from_fn_with_state(state.clone(), cache_headers)); @@ -110,6 +132,7 @@ pub fn router(state: AppState) -> Router { .route("/version", get(version)) .route("/health", get(health)) .route("/metrics", get(metrics)) + .nest(API_VERSION_PREFIX, data_routes) .nest("/v1", data_routes) .with_state(state) .layer(TimeoutLayer::new(Duration::from_secs(env_value( @@ -168,10 +191,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": endpoints, "endpoints": [ "GET /version", "GET /v1/stats", @@ -252,3 +281,128 @@ async fn metrics(headers: HeaderMap, State(state): State) -> Response ) .into_response() } + +/// 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 + } +} + +#[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 38ee6de..dcd37d8 100644 --- a/docs/app/docs/api/assets/page.mdx +++ b/docs/app/docs/api/assets/page.mdx @@ -12,7 +12,7 @@ Replace $API_BASE_URL in examples with your configured API base URL ## List assets - + Returns an array of asset objects. @@ -28,6 +28,7 @@ Returns an array of asset objects. Results are paginated to keep large collections bounded. Use `offset` and `limit` together to walk the list in chunks. ```bash +curl "http://localhost:8080/v1/assets?asset_type=real_estate&active=true" curl "$API_BASE_URL/assets?asset_type=real_estate&active=true&offset=0&limit=50" ``` @@ -55,6 +56,7 @@ curl "$API_BASE_URL/assets?asset_type=real_estate&active=true&offset=0&limit=50" **Example** ```bash +curl http://localhost:8080/v1/assets curl $API_BASE_URL/assets ``` @@ -83,7 +85,7 @@ curl $API_BASE_URL/assets ## Get an asset - + **Path parameters** @@ -94,6 +96,7 @@ curl $API_BASE_URL/assets **Example** ```bash +curl http://localhost:8080/v1/assets/1 curl $API_BASE_URL/assets/1 ``` @@ -106,7 +109,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 6470d85..bc6953d 100644 --- a/docs/app/docs/api/compliance/page.mdx +++ b/docs/app/docs/api/compliance/page.mdx @@ -6,6 +6,7 @@ export const metadata = { # Compliance + Replace $API_BASE_URL in examples with your configured API base URL (see API Overview). @@ -33,9 +34,20 @@ 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 +curl http://localhost:8080/v1/assets/1/compliance curl $API_BASE_URL/assets/1/compliance ``` diff --git a/docs/app/docs/api/dividends/page.mdx b/docs/app/docs/api/dividends/page.mdx index a801fa4..181ea60 100644 --- a/docs/app/docs/api/dividends/page.mdx +++ b/docs/app/docs/api/dividends/page.mdx @@ -6,6 +6,9 @@ export const metadata = { # Dividends +## List distributions + + Replace $API_BASE_URL in examples with your configured API base URL (see API Overview). @@ -38,6 +41,7 @@ creation ledger (newest first). **Example** ```bash +curl http://localhost:8080/v1/assets/1/dividends curl $API_BASE_URL/assets/1/dividends ``` @@ -106,3 +110,28 @@ Returns `404` with `{ "error": "not_found", … }` when the asset id or the dist 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 da78943..b202480 100644 --- a/docs/app/docs/api/holders/page.mdx +++ b/docs/app/docs/api/holders/page.mdx @@ -6,6 +6,7 @@ export const metadata = { # Holders + Replace $API_BASE_URL in examples with your configured API base URL (see API Overview). @@ -40,6 +41,7 @@ the indexer derives this from the allowlist intersected with balances. **Example** ```bash +curl http://localhost:8080/v1/assets/1/holders curl $API_BASE_URL/assets/1/holders ``` diff --git a/docs/app/docs/api/overview/page.mdx b/docs/app/docs/api/overview/page.mdx index 82fb498..6fd9c85 100644 --- a/docs/app/docs/api/overview/page.mdx +++ b/docs/app/docs/api/overview/page.mdx @@ -10,6 +10,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/v1` **Base URL:** Set via environment variable `NEXT_PUBLIC_API_BASE_URL` (defaults to `http://localhost:8080` for development). Readers should replace examples with their configured URL. @@ -71,14 +72,16 @@ via Soroban RPC. ## 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. ## Error responses diff --git a/docs/app/docs/getting-started/page.mdx b/docs/app/docs/getting-started/page.mdx index d276dfb..b718ac7 100644 --- a/docs/app/docs/getting-started/page.mdx +++ b/docs/app/docs/getting-started/page.mdx @@ -94,6 +94,8 @@ curl http://localhost:8080/health **Fetch platform-wide stats:** ```bash +curl http://localhost:8080/v1/stats +curl http://localhost:8080/v1/assets curl http://localhost:8080/stats ``` diff --git a/docs/app/docs/integration/page.mdx b/docs/app/docs/integration/page.mdx index db4ce10..ab98b03 100644 --- a/docs/app/docs/integration/page.mdx +++ b/docs/app/docs/integration/page.mdx @@ -23,6 +23,12 @@ Replace $API_BASE_URL in the examples below with your configured ba ```bash # platform stats +curl http://localhost:8080/v1/stats + +# all assets, then one asset's holders and dividends +curl http://localhost:8080/v1/assets +curl http://localhost:8080/v1/assets/1/holders +curl http://localhost:8080/v1/assets/1/dividends curl $API_BASE_URL/stats # all assets, then one asset's holders and dividends @@ -53,7 +59,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(); } @@ -170,4 +176,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 index ce26181..d70c98c 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -1,4 +1,32 @@ { + "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", "openapi": "3.0.0", "info": { "title": "Stellar RWA Indexing API", @@ -98,6 +126,9 @@ { "name": "asset_type", "in": "query", + "required": false, + "schema": { "type": "string" }, + "description": "Filter by asset class, e.g. real_estate" "description": "Filter by asset type", "schema": { "type": "string", @@ -111,6 +142,9 @@ { "name": "active", "in": "query", + "required": false, + "schema": { "type": "boolean" }, + "description": "Filter by active status" "description": "Filter by active status", "schema": { "type": "boolean" @@ -140,6 +174,55 @@ ], "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" }], "description": "Array of asset objects", "content": { "application/json": { @@ -291,6 +374,37 @@ "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" }, "application/json": { "schema": { "$ref": "#/components/schemas/ComplianceSummary" @@ -381,6 +495,8 @@ "name": "did", "in": "path", "required": true, + "schema": { "type": "integer", "format": "uint64", "minimum": 0 }, + "description": "Distribution id" "description": "Distribution ID", "schema": { "type": "integer", @@ -390,6 +506,12 @@ ], "responses": { "200": { + "description": "The distribution", + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/Distribution" } } + } + }, + "404": { "$ref": "#/components/responses/NotFound" } "description": "The distribution object", "content": { "application/json": { @@ -414,6 +536,129 @@ } }, "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"] "schemas": { "Asset": { "type": "object",