Skip to content
Closed
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
4 changes: 2 additions & 2 deletions contract/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ edition = "2021"
# Web framework
axum = "0.7"
tokio = { version = "1.35", features = ["full"] }
tower = "0.4"
tower = { version = "0.4", features = ["util"] }
tower-http = { version = "0.5", features = ["trace", "cors", "request-id"] }
url = "2"
futures = "0.3"
Expand Down Expand Up @@ -45,7 +45,7 @@ anyhow = "1.0"
# Crypto
sha2 = "0.10"
hex = "0.4"
chrono = "0.4.43"
chrono = { version = "0.4.43", features = ["serde"] }
base64 = "0.22"
rand = "0.8"
uuid = { version = "1", features = ["v4"] }
Expand Down
53 changes: 52 additions & 1 deletion contract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ pub mod webhook;

use axum::{
body::Body,
extract::{Path, State},
extract::{Path, Query, State},
http::{HeaderName, Request, StatusCode},
middleware::Next,
response::{IntoResponse, Response},
Expand All @@ -37,6 +37,7 @@ use cache::CacheBackend;
use event::Event;
use hash_validator::{HashValidator, ValidationError as HashValidationError};
use metrics::MetricsRegistry;
use module::ownership_chain::pagination;
use stellar::{derive_account_id, StellarClient, TransactionRecord};

/// Header used to correlate a single logical operation across the NestJS
Expand Down Expand Up @@ -145,8 +146,12 @@ pub struct HealthResponse {
pub struct HistoryResponse {
pub document_hash: String,
pub transactions: Vec<TransactionRecord>,
/// Total records in the document's chain, independent of the page returned.
pub count: usize,
pub cached: bool,
/// Cursor for the following page when the caller asked for one (#1346);
/// `null` when the whole chain was returned or it is exhausted.
pub next_cursor: Option<usize>,
}

#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
Expand Down Expand Up @@ -523,6 +528,40 @@ pub async fn record_transfer(
}))
}

/// Default number of ownership-chain records returned per page (#1346).
pub const DEFAULT_CHAIN_PAGE_SIZE: usize = 20;

/// Upper bound a caller may request for `page_size` (#1346).
pub const MAX_CHAIN_PAGE_SIZE: usize = 200;

/// Query parameters for a cursor-paginated ownership-chain lookup (#1346).
#[derive(Debug, Deserialize)]
pub struct ChainHistoryQuery {
/// Offset into the chain to start from; 0 when omitted.
pub cursor: Option<usize>,
/// Records to return; defaults to [`DEFAULT_CHAIN_PAGE_SIZE`], capped at
/// [`MAX_CHAIN_PAGE_SIZE`].
pub page_size: Option<usize>,
}

/// Resolve the requested page, or `None` when the caller asked for no paging.
///
/// `None` keeps the pre-existing response shape (the full array), so clients
/// written before #1346 are unaffected; passing either parameter opts in.
fn pagination_params(query: &ChainHistoryQuery) -> Option<(usize, usize)> {
if query.cursor.is_none() && query.page_size.is_none() {
return None;
}

Some((
query.cursor.unwrap_or(0),
query
.page_size
.unwrap_or(DEFAULT_CHAIN_PAGE_SIZE)
.clamp(1, MAX_CHAIN_PAGE_SIZE),
))
}

/// GET /transfer/:document_hash — retrieve transfer history for a document.
pub async fn get_transfer_history(
State(state): State<AppState>,
Expand Down Expand Up @@ -636,6 +675,7 @@ pub async fn verify_document_by_hash(
pub async fn verify_document_history(
State(state): State<AppState>,
Path(hash): Path<String>,
Query(query): Query<ChainHistoryQuery>,
) -> Response {
let normalized_hash = HashValidator::normalize(&hash);
if let Err(err) = HashValidator::validate_sha256(&normalized_hash) {
Expand All @@ -656,11 +696,22 @@ pub async fn verify_document_history(
let count = transactions.len();
let cached = !transactions.is_empty();

// #1346: page the chain only when the caller asks for it, so existing
// clients keep receiving the full list they always got.
let (transactions, next_cursor) = match pagination_params(&query) {
None => (transactions, None),
Some((cursor, page_size)) => {
let page = pagination::paginate(&transactions, cursor, page_size);
(page.items, page.next_cursor)
}
};

Json(HistoryResponse {
document_hash: normalized_hash,
transactions,
count,
cached,
next_cursor,
})
.into_response()
}
Expand Down
2 changes: 2 additions & 0 deletions contract/src/module/ownership_chain/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
//! Routes wired in `lib.rs`:
//! GET /module/chain/:document_hash → [`chain_handler`]

pub mod pagination;

use axum::{
extract::{Path, State},
http::StatusCode,
Expand Down
75 changes: 71 additions & 4 deletions contract/src/module/ownership_chain/pagination.rs
Original file line number Diff line number Diff line change
@@ -1,18 +1,85 @@
//! Cursor-based pagination for the ownership chain history response, so
//! a document with a long transfer history is served in bounded pages.
//! Cursor-based pagination for ownership-chain history responses, so a
//! document with a long transfer history is served in bounded pages (#1346).

/// One bounded page of items plus the cursor that fetches the next one.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Page<T> {
pub items: Vec<T>,
pub next_cursor: Option<usize>,
}

/// Slice `items` into a single page.
///
/// A `cursor` past the end of `items` yields an empty page instead of panicking
/// (a caller that paginates while the chain is being trimmed would otherwise get
/// a 500), and `page_size` of 0 is treated as 1 so a page always makes progress.
pub fn paginate<T: Clone>(items: &[T], cursor: usize, page_size: usize) -> Page<T> {
let end = std::cmp::min(cursor + page_size, items.len());
let slice = items[cursor..end].to_vec();
let page_size = page_size.max(1);
let start = cursor.min(items.len());
let end = std::cmp::min(start + page_size, items.len());
let slice = items[start..end].to_vec();
let next_cursor = if end < items.len() { Some(end) } else { None };

Page {
items: slice,
next_cursor,
}
}

#[cfg(test)]
mod tests {
use super::*;

fn items(n: usize) -> Vec<usize> {
(0..n).collect()
}

#[test]
fn first_page_reports_the_next_cursor() {
let page = paginate(&items(5), 0, 2);
assert_eq!(page.items, vec![0, 1]);
assert_eq!(page.next_cursor, Some(2));
}

#[test]
fn last_page_has_no_next_cursor() {
let page = paginate(&items(5), 4, 2);
assert_eq!(page.items, vec![4]);
assert_eq!(page.next_cursor, None);
}

#[test]
fn exact_fit_has_no_next_cursor() {
let page = paginate(&items(4), 2, 2);
assert_eq!(page.items, vec![2, 3]);
assert_eq!(page.next_cursor, None);
}

#[test]
fn cursor_past_the_end_is_an_empty_page_not_a_panic() {
let page = paginate(&items(3), 99, 10);
assert!(page.items.is_empty());
assert_eq!(page.next_cursor, None);
}

#[test]
fn cursor_at_the_end_is_an_empty_page() {
let page = paginate(&items(3), 3, 10);
assert!(page.items.is_empty());
assert_eq!(page.next_cursor, None);
}

#[test]
fn zero_page_size_still_makes_progress() {
let page = paginate(&items(3), 0, 0);
assert_eq!(page.items, vec![0]);
assert_eq!(page.next_cursor, Some(1));
}

#[test]
fn empty_input_is_an_empty_page() {
let page: Page<usize> = paginate(&items(0), 0, 10);
assert!(page.items.is_empty());
assert_eq!(page.next_cursor, None);
}
}
14 changes: 9 additions & 5 deletions contract/src/rate_limit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,14 @@
use governor::{Quota, RateLimiter};
use std::num::NonZeroU32;

pub type DefaultRateLimiter = RateLimiter<
governor::state::NotKeyed,
governor::state::InMemoryState,
governor::clock::DefaultClock,
/// `Arc`-wrapped because `AppState` derives `Clone` and `governor::RateLimiter`
/// is not itself `Clone`.
pub type DefaultRateLimiter = std::sync::Arc<
RateLimiter<
governor::state::NotKeyed,
governor::state::InMemoryState,
governor::clock::DefaultClock,
>,
>;

/// Build an in-memory, non-keyed rate limiter allowing `per_second`
Expand All @@ -22,7 +26,7 @@ pub type DefaultRateLimiter = RateLimiter<
pub fn build_rate_limiter(per_second: u32, burst: u32) -> DefaultRateLimiter {
let quota = Quota::per_second(NonZeroU32::new(per_second).unwrap())
.allow_burst(NonZeroU32::new(burst).unwrap());
RateLimiter::direct(quota)
std::sync::Arc::new(RateLimiter::direct(quota))
}

#[cfg(test)]
Expand Down
2 changes: 1 addition & 1 deletion contract/src/stellar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -778,7 +778,7 @@ mod tests {
when.method(GET).path(format!("/accounts/{}", TEST_ACCOUNT));
then.status(200).json_body(serde_json::json!({
"sequence": "1",
"data": { (data_key): raw_value }
"data": { (data_key.clone()): raw_value }
}));
});

Expand Down
106 changes: 106 additions & 0 deletions contract/tests/handler_integration_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -400,3 +400,109 @@ async fn test_transfer_with_invalid_date_returns_400() {

assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}

// ── #1346: ownership-chain pagination on the mounted history endpoint ───────

use stellar_doc_verifier::stellar::TransactionRecord;

const CHAIN_HASH: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";

fn chain_records(n: usize) -> Vec<TransactionRecord> {
(0..n)
.map(|i| TransactionRecord {
transaction_id: format!("tx-{:03}", i),
timestamp: 1_700_000_000 + i as i64,
verified: true,
})
.collect()
}

async fn state_with_chain(records: Vec<TransactionRecord>) -> AppState {
let cache = CacheBackend::InMemory(InMemoryCache::new());
cache
.set(&format!("history:{}", CHAIN_HASH), &records, 3600)
.await
.unwrap();
AppState {
stellar: Arc::new(StellarClient::new("https://horizon-testnet.stellar.org")),
cache: Arc::new(cache),
metrics: Arc::new(MetricsRegistry::new()),
stellar_secret_key: SECRET.to_string(),
rate_limiter: build_rate_limiter(1000, 1000),
webhook_urls: Vec::new(),
webhook_secret: None,
}
}

async fn get_chain(
query: &str,
records: Vec<TransactionRecord>,
) -> (StatusCode, serde_json::Value) {
let router = app(state_with_chain(records).await);
let response = router
.oneshot(
Request::builder()
.uri(format!("/verify/{}{}", CHAIN_HASH, query))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
(status, serde_json::from_slice(&body).unwrap())
}

#[tokio::test]
async fn test_verify_history_without_params_returns_the_whole_chain() {
let (status, json) = get_chain("/history", chain_records(5)).await;

assert_eq!(status, StatusCode::OK);
assert_eq!(json["count"], 5);
assert_eq!(json["transactions"].as_array().unwrap().len(), 5);
assert!(json["next_cursor"].is_null());
}

#[tokio::test]
async fn test_verify_history_first_page_reports_next_cursor() {
let (status, json) = get_chain("/history?cursor=0&page_size=2", chain_records(5)).await;

assert_eq!(status, StatusCode::OK);
assert_eq!(json["count"], 5);
assert_eq!(json["next_cursor"], 2);
let page = json["transactions"].as_array().unwrap();
assert_eq!(page.len(), 2);
assert_eq!(page[0]["transaction_id"], "tx-000");
}

#[tokio::test]
async fn test_verify_history_last_page_has_null_next_cursor() {
let (status, json) = get_chain("/history?cursor=4&page_size=2", chain_records(5)).await;

assert_eq!(status, StatusCode::OK);
assert_eq!(json["count"], 5);
assert_eq!(json["transactions"].as_array().unwrap().len(), 1);
assert!(json["next_cursor"].is_null());
}

#[tokio::test]
async fn test_verify_history_cursor_past_the_end_is_empty_not_500() {
let (status, json) = get_chain("/history?cursor=99&page_size=2", chain_records(5)).await;

assert_eq!(status, StatusCode::OK);
assert_eq!(json["count"], 5);
assert!(json["transactions"].as_array().unwrap().is_empty());
assert!(json["next_cursor"].is_null());
}

#[tokio::test]
async fn test_verify_history_page_size_is_capped_at_the_maximum() {
let (status, json) = get_chain("/history?page_size=100000", chain_records(250)).await;

assert_eq!(status, StatusCode::OK);
assert_eq!(json["count"], 250);
assert_eq!(json["transactions"].as_array().unwrap().len(), 200);
assert_eq!(json["next_cursor"], 200);
}