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
22 changes: 17 additions & 5 deletions contract/src/handlers/verify.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use axum::{
extract::{Path, State},
extract::{Path, Query, State},
http::StatusCode,
response::{IntoResponse, Response},
Json,
Expand All @@ -8,12 +8,15 @@ use futures::future::join_all;
use tracing::{info, warn};

use crate::hash_validator::{HashValidator, ValidationError as HashValidationError};
use crate::module::ownership_chain::pagination::{self, DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE};
use crate::stellar::derive_account_id;
use crate::types::{
map_validation_error, AppState, BatchVerifyItem, BatchVerifyRequest, BatchVerifyResponse,
HistoryResponse, ValidationErrorResponse, VerifyRequest, VerifyResponse,
HistoryQuery, HistoryResponse, ValidationErrorResponse, VerifyRequest, VerifyResponse,
};



// Verify document by POST
pub async fn verify_document(
State(state): State<AppState>,
Expand Down Expand Up @@ -88,6 +91,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<HistoryQuery>,
) -> Response {
let normalized_hash = HashValidator::normalize(&hash);
if let Err(err) = HashValidator::validate_sha256(&normalized_hash) {
Expand All @@ -106,13 +110,21 @@ pub async fn verify_document_history(
}
};

let count = transactions.len();
let total = transactions.len();
let cached = !transactions.is_empty();

let page_size = query
.page_size
.unwrap_or(DEFAULT_PAGE_SIZE)
.clamp(1, MAX_PAGE_SIZE);
let page = pagination::paginate(&transactions, query.cursor.unwrap_or(0), page_size);

Json(HistoryResponse {
document_hash: normalized_hash,
transactions,
count,
count: page.items.len(),
total,
next_cursor: page.next_cursor,
transactions: page.items,
cached,
})
.into_response()
Expand Down
2 changes: 1 addition & 1 deletion contract/src/health.rs
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ mod tests {
cache: Arc::new(CacheBackend::InMemory(InMemoryCache::new())),
metrics: Arc::new(MetricsRegistry::new()),
stellar_secret_key: String::new(),
rate_limiter: build_rate_limiter(1000, 1000),
rate_limiter: Arc::new(build_rate_limiter(1000, 1000)),
webhook_urls: Vec::new(),
webhook_secret: None,
}
Expand Down
28 changes: 24 additions & 4 deletions contract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ pub mod types;
pub mod webhook;

use axum::{
extract::Query,
body::Body,
extract::{Path, State},
http::{HeaderName, Request, StatusCode},
Expand All @@ -24,6 +25,8 @@ use axum::{
};
use chrono::{NaiveDate, Utc};
use futures::future::join_all;
use crate::module::ownership_chain::pagination;
use crate::types::HistoryQuery;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
Expand Down Expand Up @@ -74,7 +77,7 @@ pub struct AppState {
pub stellar_secret_key: String,
/// Governor-based rate limiter built from `RATE_LIMIT_PER_SECOND` /
/// `RATE_LIMIT_BURST` and enforced as a router middleware (CT-37).
pub rate_limiter: rate_limit::DefaultRateLimiter,
pub rate_limiter: Arc<rate_limit::DefaultRateLimiter>,
/// Comma-separated webhook URLs parsed from `WEBHOOK_URLS` (CT-38).
pub webhook_urls: Vec<String>,
/// Shared secret used to sign webhook payloads (CT-38).
Expand Down Expand Up @@ -144,8 +147,14 @@ pub struct HealthResponse {
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
pub struct HistoryResponse {
pub document_hash: String,
/// The transactions in this page, not the whole history.
pub transactions: Vec<TransactionRecord>,
/// How many transactions this page carries.
pub count: usize,
/// How many the chain holds in total.
pub total: usize,
/// Cursor to pass back as `?cursor=` for the next page, or null at the end.
pub next_cursor: Option<usize>,
pub cached: bool,
}

Expand Down Expand Up @@ -636,6 +645,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<HistoryQuery>,
) -> Response {
let normalized_hash = HashValidator::normalize(&hash);
if let Err(err) = HashValidator::validate_sha256(&normalized_hash) {
Expand All @@ -653,13 +663,23 @@ pub async fn verify_document_history(
}
};

let count = transactions.len();
let total = transactions.len();
let cached = !transactions.is_empty();

// Dead copy of the handler in handlers/verify.rs, which is the one the
// router wires; kept in step with it so the two cannot drift apart.
let page_size = query
.page_size
.unwrap_or(pagination::DEFAULT_PAGE_SIZE)
.clamp(1, pagination::MAX_PAGE_SIZE);
let page = pagination::paginate(&transactions, query.cursor.unwrap_or(0), page_size);

Json(HistoryResponse {
document_hash: normalized_hash,
transactions,
count,
count: page.items.len(),
total,
next_cursor: page.next_cursor,
transactions: page.items,
cached,
})
.into_response()
Expand Down
4 changes: 2 additions & 2 deletions contract/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
cache: cache.clone(),
metrics,
stellar_secret_key: config.stellar_secret_key.clone().unwrap_or_default(),
rate_limiter: build_rate_limiter(
rate_limiter: Arc::new(build_rate_limiter(
config.rate_limit_per_second,
config.rate_limit_burst,
),
)),
webhook_urls: config.webhook_urls.clone(),
webhook_secret: config.webhook_secret.clone(),
};
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 @@ -16,6 +16,8 @@ use axum::{
use serde::Serialize;
use std::collections::HashSet;

pub mod pagination;

use crate::{cache::CacheBackend, AppState, TransferRecord};

// ────────────────────────────────────────────────────────────────────────────
Expand Down
85 changes: 81 additions & 4 deletions contract/src/module/ownership_chain/pagination.rs
Original file line number Diff line number Diff line change
@@ -1,18 +1,95 @@
//! Cursor-based pagination for the ownership chain history response, so
//! a document with a long transfer history is served in bounded pages.

use serde::Serialize;

/// Page size used when the client does not ask for one.
pub const DEFAULT_PAGE_SIZE: usize = 50;
/// Upper bound on a requested page size, so a client cannot ask for the whole
/// chain in one request.
pub const MAX_PAGE_SIZE: usize = 200;

#[derive(Debug, Clone, Serialize)]
pub struct Page<T> {
pub items: Vec<T>,
pub next_cursor: Option<usize>,
}

/// Slice `items` for the page that starts at `cursor`.
///
/// A cursor past the end yields an empty page rather than panicking, and a page
/// size of zero yields an empty page with no next cursor rather than a cursor
/// that never advances. Both are inputs a client can send.
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 next_cursor = if end < items.len() { Some(end) } else { None };
let start = std::cmp::min(cursor, items.len());
let end = std::cmp::min(start.saturating_add(page_size), items.len());
let next_cursor = if page_size > 0 && end < items.len() {
Some(end)
} else {
None
};

Page {
items: slice,
items: items[start..end].to_vec(),
next_cursor,
}
}

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

fn items() -> Vec<u32> {
(1..=10).collect()
}

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

#[test]
fn a_middle_page_continues_from_its_cursor() {
let page = paginate(&items(), 3, 3);
assert_eq!(page.items, vec![4, 5, 6]);
assert_eq!(page.next_cursor, Some(6));
}

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

#[test]
fn a_page_that_covers_the_remainder_has_no_next_cursor() {
let page = paginate(&items(), 2, 100);
assert_eq!(page.items, vec![3, 4, 5, 6, 7, 8, 9, 10]);
assert_eq!(page.next_cursor, None);
}

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

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

#[test]
fn an_empty_chain_is_an_empty_page() {
let empty: Vec<u32> = Vec::new();
let page = paginate(&empty, 0, 10);
assert!(page.items.is_empty());
assert_eq!(page.next_cursor, None);
}
}
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
22 changes: 22 additions & 0 deletions contract/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,28 @@ pub struct HealthResponse {
pub redis_connected: bool,
}

/// Query parameters for the paginated history endpoint.
#[derive(Debug, Deserialize)]
pub struct HistoryQuery {
/// Index to start from; absent means the first page.
pub cursor: Option<usize>,
/// Page size; absent means the default, and anything above the cap is
/// clamped so a client cannot ask for the whole chain in one request.
pub page_size: Option<usize>,
}

/// Response type for document verification history
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
pub struct HistoryResponse {
pub document_hash: String,
/// The transactions in this page, not the whole history.
pub transactions: Vec<TransactionRecord>,
/// How many transactions this page carries.
pub count: usize,
/// How many the chain holds in total.
pub total: usize,
/// Cursor to pass back as `?cursor=` for the next page, or null at the end.
pub next_cursor: Option<usize>,
pub cached: bool,
}

Expand Down Expand Up @@ -692,6 +708,8 @@ mod tests {
.to_string(),
transactions: vec![],
count: 0,
total: 0,
next_cursor: None,
cached: false,
};
assert_serde_round_trip(&resp_empty);
Expand All @@ -712,6 +730,8 @@ mod tests {
},
],
count: 2,
total: 2,
next_cursor: None,
cached: true,
};
assert_serde_round_trip(&resp_with_records);
Expand Down Expand Up @@ -882,6 +902,8 @@ mod tests {
document_hash: "hash123".to_string(),
transactions: vec![],
count: 0,
total: 0,
next_cursor: None,
cached: false,
});
assert_serde_round_trip(&ValidationErrorResponse {
Expand Down
Loading