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
19 changes: 19 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,25 @@ npm run build # must pass before you open a PR
3. Run `npm run build` in `docs/` and make sure it passes.
4. Open a PR with a clear description and, for content changes, a screenshot.

## Release checklist

When releasing a new version of the API or contracts:

1. **Update `api/Cargo.toml`** with the new version number (if applicable).
2. **Update `CHANGELOG.md`** with all user-facing changes:
- API endpoint changes, new endpoints, breaking changes.
- Contract changes (new functions, parameter changes).
- Document the date and version.
3. **Update `docs/`** to reflect the changes:
- Add or modify endpoint references in `docs/pages/api/`.
- Update contract guides in `docs/pages/contracts/` if applicable.
- Update examples and code snippets throughout.
4. **Run `npm run build` in `docs/`** to ensure all links and references are valid.
5. **Create a tagged release** on GitHub with a summary linking to the CHANGELOG entry.

This ensures readers can map documentation versions to releases and find the
exact API/contract behavior that was current when the docs were published.

## Reporting API issues

Found a problem with the API? Open an issue with:
Expand Down
84 changes: 66 additions & 18 deletions api/src/indexer/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// SPDX-License-Identifier: Apache-2.0

//! In-memory indexer for tokenized RWA activity on Stellar.
//!
//! Every 10 seconds the indexer reads the current on-chain state of the four
Expand Down Expand Up @@ -120,6 +122,7 @@ pub struct AppState {
inner: ArcSwap<Snapshot>,
pub config: Arc<Config>,
pub metrics: PrometheusHandle,
initialized: Arc<std::sync::atomic::AtomicBool>,
}

impl AppState {
Expand All @@ -128,6 +131,7 @@ impl AppState {
inner: ArcSwap::from_arc(Arc::new(Snapshot::default())),
config: Arc::new(config),
metrics,
initialized: Arc::new(std::sync::atomic::AtomicBool::new(false)),
}
}

Expand All @@ -139,6 +143,17 @@ impl AppState {

fn replace(&self, next: Snapshot) {
self.inner.store(Arc::new(next));
self.initialized.store(true, std::sync::atomic::Ordering::SeqCst);
}

/// Returns whether the first successful refresh has completed.
pub fn is_initialized(&self) -> bool {
self.initialized.load(std::sync::atomic::Ordering::SeqCst)
}

/// Returns the latest indexed ledger, or 0 if not yet initialized.
pub async fn last_indexed_ledger(&self) -> u32 {
self.snapshot().stats.last_indexed_ledger
}
}

Expand Down Expand Up @@ -309,26 +324,29 @@ impl Rpc {
}

let resp: RpcEnvelope = resp.json().await?;
process_rpc_envelope(resp)
}
}

if let Some(err) = resp.error {
return Err(IndexError::Rpc(err.message));
}
let result = resp
.result
.ok_or_else(|| IndexError::Rpc("empty rpc result".into()))?;
if let Some(sim_err) = result.error {
return Err(IndexError::Rpc(sim_err));
}
let entry = result
.results
.first()
.ok_or_else(|| IndexError::Rpc("no simulation result".into()))?;
let scval = xdr::ScVal::from_xdr_base64(&entry.xdr, Limits::none())?;
Ok(ReadOutcome {
value: scval_to_json(&scval)?,
latest_ledger: result.latest_ledger,
})
fn process_rpc_envelope(resp: RpcEnvelope) -> Result<ReadOutcome, IndexError> {
if let Some(err) = resp.error {
return Err(IndexError::Rpc(err.message));
}
let result = resp
.result
.ok_or_else(|| IndexError::Rpc("empty rpc result".into()))?;
if let Some(sim_err) = result.error {
return Err(IndexError::Rpc(sim_err));
}
let entry = result
.results
.first()
.ok_or_else(|| IndexError::Rpc("no simulation result".into()))?;
let scval = xdr::ScVal::from_xdr_base64(&entry.xdr, Limits::none())?;
Ok(ReadOutcome {
value: scval_to_json(&scval)?,
latest_ledger: result.latest_ledger,
})
}

/// Jittered exponential backoff for the `attempt`-th failed read (1-indexed).
Expand Down Expand Up @@ -935,4 +953,34 @@ mod tests {
json!({ "active": true, "id": 1 })
);
}

#[test]
fn handles_envelope_level_error() {
let resp = RpcEnvelope {
error: Some(RpcError {
message: "node busy".into(),
}),
result: None,
};
match process_rpc_envelope(resp) {
Err(IndexError::Rpc(msg)) => assert_eq!(msg, "node busy"),
other => panic!("expected Rpc error, got: {other:?}"),
}
}

#[test]
fn handles_simulation_level_error() {
let resp = RpcEnvelope {
error: None,
result: Some(SimulateResult {
results: vec![],
error: Some("contract failed".into()),
latest_ledger: 100,
}),
};
match process_rpc_envelope(resp) {
Err(IndexError::Rpc(msg)) => assert_eq!(msg, "contract failed"),
other => panic!("expected Rpc error, got: {other:?}"),
}
}
}
2 changes: 2 additions & 0 deletions api/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// SPDX-License-Identifier: Apache-2.0

//! Stellar RWA API — a read-only REST index of tokenized real-world asset
//! activity on Stellar.
//!
Expand Down
2 changes: 2 additions & 0 deletions api/src/models/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// SPDX-License-Identifier: Apache-2.0

//! Serializable domain models returned by the REST API.
//!
//! On-chain, monetary valuations are stored as USD cents (`i128`) and token
Expand Down
2 changes: 2 additions & 0 deletions api/src/routes/assets.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// SPDX-License-Identifier: Apache-2.0

//! `GET /assets` and `GET /assets/:id`.

use axum::{
Expand Down
2 changes: 2 additions & 0 deletions api/src/routes/compliance.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// SPDX-License-Identifier: Apache-2.0

//! `GET /assets/:id/compliance`.

use axum::{
Expand Down
2 changes: 2 additions & 0 deletions api/src/routes/dividends.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// SPDX-License-Identifier: Apache-2.0

//! `GET /assets/:id/dividends`.

use axum::{
Expand Down
2 changes: 2 additions & 0 deletions api/src/routes/holders.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// SPDX-License-Identifier: Apache-2.0

//! `GET /assets/:id/holders`.

use axum::{
Expand Down
10 changes: 10 additions & 0 deletions api/src/routes/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// SPDX-License-Identifier: Apache-2.0

//! HTTP routing and the shared API error type.

pub mod assets;
Expand Down Expand Up @@ -98,6 +100,14 @@ pub fn router(state: AppState) -> Router {
/// derived from `last_indexed_ledger`: two requests against the same indexed
/// ledger are guaranteed to have identical bodies.
async fn cache_headers(State(state): State<AppState>, req: Request<Body>, next: Next) -> Response {
if !state.is_initialized() {
return Response::builder()
.status(StatusCode::SERVICE_UNAVAILABLE)
.header(header::RETRY_AFTER, POLL_INTERVAL.as_secs().to_string())
.body(Body::empty())
.expect("static 503 response is well-formed");
}

let ledger = state.last_indexed_ledger().await;
let etag = format!("\"ledger-{ledger}\"");

Expand Down
2 changes: 2 additions & 0 deletions api/src/routes/stats.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// SPDX-License-Identifier: Apache-2.0

//! `GET /stats`.

use axum::{extract::State, Json};
Expand Down