Skip to content

Commit 570fa1c

Browse files
Merge pull request #947 from okonkwofreeman001/feat/wire-batch-initiate-endpoint
feat(api-server): wire batch-initiate endpoint to contract batch semantics
2 parents 62a7fbf + fe5fd59 commit 570fa1c

2 files changed

Lines changed: 226 additions & 9 deletions

File tree

api-server/src/handlers.rs

Lines changed: 85 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use axum::{
77
use once_cell::sync::Lazy;
88
use serde_json::Value;
99
use std::collections::HashSet;
10+
use std::sync::atomic::{AtomicU64, Ordering};
1011
use tokio::time::{Duration, Instant};
1112
use tracing::instrument;
1213
use crate::cache;
@@ -19,6 +20,24 @@ use crate::webhook;
1920
// #523: Per-handler idempotency store for batch swap operations.
2021
static BATCH_SWAP_IDEMPOTENCY: Lazy<DeduplicationStore> = Lazy::new(create_store);
2122

23+
// #520: Mirrors the contract's MAX_BATCH_SIZE cap on a single batch.
24+
const MAX_BATCH_SIZE: usize = 50;
25+
26+
// #469: Mirrors the contract's ~7-day swap expiry (ledger timestamp + 604800).
27+
const SWAP_EXPIRY_SECONDS: u64 = 604800;
28+
29+
/// Process-local swap ID counter standing in for the contract's `NextId`
30+
/// until the handlers are wired to a live Soroban RPC client.
31+
static NEXT_SWAP_ID: AtomicU64 = AtomicU64::new(0);
32+
33+
/// Current Unix timestamp in seconds (substitute for the ledger timestamp).
34+
fn now_timestamp() -> u64 {
35+
std::time::SystemTime::now()
36+
.duration_since(std::time::UNIX_EPOCH)
37+
.map(|duration| duration.as_secs())
38+
.unwrap_or(0)
39+
}
40+
2241
// ── IP Registry ───────────────────────────────────────────────────────────────
2342

2443
/// Timestamp a new IP commitment. Returns the assigned IP ID.
@@ -307,7 +326,7 @@ pub async fn initiate_swap(Json(body): Json<InitiateSwapRequest>) -> Result<Json
307326
request_body = BatchInitiateSwapRequest,
308327
responses(
309328
(status = 200, description = "Swaps initiated, returns swap_ids", body = BatchInitiateSwapResponse),
310-
(status = 400, description = "Validation error (mismatched lengths, invalid IP, etc.)", body = ErrorResponse),
329+
(status = 400, description = "Validation error (mismatched lengths, empty/oversized batch, non-positive price, duplicate ip_ids, etc.)", body = ErrorResponse),
311330
)
312331
)]
313332
#[instrument(skip(body))]
@@ -358,14 +377,71 @@ pub async fn batch_initiate_swap(Json(body): Json<BatchInitiateSwapRequest>) ->
358377
}
359378
}
360379

361-
// TODO: Call Soroban RPC to invoke atomic_swap.batch_initiate_swap
362-
// On success, cache the result: BATCH_SWAP_IDEMPOTENCY.insert(key, (json_value, Instant::now()));
363-
Err((
364-
StatusCode::BAD_REQUEST,
365-
Json(ErrorResponse {
366-
error: "batch_initiate_swap not yet implemented".to_string(),
367-
}),
368-
))
380+
// #520: Cap the batch size at the contract's MAX_BATCH_SIZE (50).
381+
if body.ip_ids.len() > MAX_BATCH_SIZE {
382+
return Err((
383+
StatusCode::BAD_REQUEST,
384+
Json(ErrorResponse {
385+
error: format!(
386+
"batch size {} exceeds maximum of {}",
387+
body.ip_ids.len(),
388+
MAX_BATCH_SIZE
389+
),
390+
}),
391+
));
392+
}
393+
394+
// #520: Every price must be positive (contract: require_positive_price).
395+
if let Some((&ip_id, _)) = body
396+
.ip_ids
397+
.iter()
398+
.zip(body.prices.iter())
399+
.find(|(_, &price)| price <= 0)
400+
{
401+
return Err((
402+
StatusCode::BAD_REQUEST,
403+
Json(ErrorResponse {
404+
error: format!("price must be positive for ip_id {}", ip_id),
405+
}),
406+
));
407+
}
408+
409+
// Reuse the batch semantics of the contract's `batch_initiate_swap`
410+
// (validated in the contract's batch tests): every IP in the batch gets a
411+
// fresh Pending swap with a ~7-day expiry, IDs allocated sequentially
412+
// (contract NextId). Until the handlers are wired to a live Soroban RPC
413+
// client, the records are served back through the #316 cache so
414+
// GET /swap/{swap_id} can read them.
415+
let expiry = now_timestamp() + SWAP_EXPIRY_SECONDS;
416+
let mut swap_ids = Vec::with_capacity(body.ip_ids.len());
417+
for (&ip_id, &price) in body.ip_ids.iter().zip(body.prices.iter()) {
418+
let swap_id = NEXT_SWAP_ID.fetch_add(1, Ordering::Relaxed);
419+
let record = SwapRecord {
420+
ip_id,
421+
ip_registry_id: body.ip_registry_id.clone(),
422+
seller: body.seller.clone(),
423+
buyer: body.buyer.clone(),
424+
price,
425+
token: body.token.clone(),
426+
status: SwapStatus::Pending,
427+
expiry,
428+
};
429+
cache::set_with_ttl(&cache::swap_key(swap_id), &record, SWAP_EXPIRY_SECONDS);
430+
swap_ids.push(swap_id);
431+
}
432+
433+
let response = BatchInitiateSwapResponse { swap_ids };
434+
435+
// #523: Cache the result under the idempotency key so replays return the
436+
// same swap IDs instead of allocating new ones.
437+
if let Some(ref key) = body.idempotency_key {
438+
BATCH_SWAP_IDEMPOTENCY.insert(
439+
key.clone(),
440+
(serde_json::to_value(&response).unwrap(), Instant::now()),
441+
);
442+
}
443+
444+
Ok(Json(response))
369445
}
370446

371447
/// Buyer accepts a pending swap.

api-server/src/main.rs

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -530,6 +530,147 @@ mod tests {
530530
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
531531
}
532532

533+
#[tokio::test]
534+
async fn test_batch_initiate_swap_success_returns_pending_swaps() {
535+
let app = build_app();
536+
let resp = app
537+
.oneshot(
538+
Request::builder()
539+
.method("POST")
540+
.uri("/v1/swap/bulk/initiate")
541+
.header("content-type", "application/json")
542+
.body(Body::from(r#"{"ip_registry_id":"C1","ip_ids":[1,2,3],"seller":"G1","prices":[100,200,300],"buyer":"G2","token":"C2"}"#))
543+
.unwrap(),
544+
)
545+
.await
546+
.unwrap();
547+
assert_eq!(resp.status(), StatusCode::OK);
548+
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
549+
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
550+
let swap_ids: Vec<u64> = json["swap_ids"]
551+
.as_array()
552+
.unwrap()
553+
.iter()
554+
.map(|id| id.as_u64().unwrap())
555+
.collect();
556+
assert_eq!(swap_ids.len(), 3);
557+
// IDs are allocated sequentially, mirroring the contract's NextId.
558+
assert_eq!(swap_ids[0] + 1, swap_ids[1]);
559+
assert_eq!(swap_ids[1] + 1, swap_ids[2]);
560+
561+
// The created swaps are readable via GET /swap/{id} as Pending with a
562+
// ~7-day expiry, matching the contract's batch_initiate_swap.
563+
for swap_id in swap_ids {
564+
let resp = app
565+
.clone()
566+
.oneshot(
567+
Request::builder()
568+
.method("GET")
569+
.uri(format!("/v1/swap/{}", swap_id))
570+
.body(Body::empty())
571+
.unwrap(),
572+
)
573+
.await
574+
.unwrap();
575+
assert_eq!(resp.status(), StatusCode::OK);
576+
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
577+
let record: serde_json::Value = serde_json::from_slice(&body).unwrap();
578+
assert_eq!(record["status"], "Pending");
579+
let now = std::time::SystemTime::now()
580+
.duration_since(std::time::UNIX_EPOCH)
581+
.unwrap()
582+
.as_secs();
583+
assert!(record["expiry"].as_u64().unwrap() > now);
584+
}
585+
}
586+
587+
#[tokio::test]
588+
async fn test_batch_initiate_swap_too_large_returns_400() {
589+
let app = build_app();
590+
let ip_ids: Vec<String> = (1..=51).map(|i| i.to_string()).collect();
591+
let prices: Vec<String> = (1..=51).map(|i| (i * 100).to_string()).collect();
592+
let body = format!(
593+
r#"{{"ip_registry_id":"C1","ip_ids":[{}],"seller":"G1","prices":[{}],"buyer":"G2","token":"C2"}}"#,
594+
ip_ids.join(","),
595+
prices.join(",")
596+
);
597+
let resp = app
598+
.oneshot(
599+
Request::builder()
600+
.method("POST")
601+
.uri("/v1/swap/bulk/initiate")
602+
.header("content-type", "application/json")
603+
.body(Body::from(body))
604+
.unwrap(),
605+
)
606+
.await
607+
.unwrap();
608+
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
609+
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
610+
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
611+
assert!(json["error"].as_str().unwrap().contains("exceeds maximum"));
612+
}
613+
614+
#[tokio::test]
615+
async fn test_batch_initiate_swap_non_positive_price_returns_400() {
616+
let app = build_app();
617+
let resp = app
618+
.oneshot(
619+
Request::builder()
620+
.method("POST")
621+
.uri("/v1/swap/bulk/initiate")
622+
.header("content-type", "application/json")
623+
.body(Body::from(r#"{"ip_registry_id":"C1","ip_ids":[1,2],"seller":"G1","prices":[100,0],"buyer":"G2","token":"C2"}"#))
624+
.unwrap(),
625+
)
626+
.await
627+
.unwrap();
628+
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
629+
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
630+
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
631+
assert!(json["error"].as_str().unwrap().contains("positive"));
632+
}
633+
634+
#[tokio::test]
635+
async fn test_batch_initiate_swap_idempotent_replay_returns_same_ids() {
636+
let app = build_app();
637+
let body = r#"{"ip_registry_id":"C1","ip_ids":[10,11],"seller":"G1","prices":[100,200],"buyer":"G2","token":"C2","idempotency_key":"batch-init-test-key"}"#;
638+
let send = || {
639+
app.clone().oneshot(
640+
Request::builder()
641+
.method("POST")
642+
.uri("/v1/swap/bulk/initiate")
643+
.header("content-type", "application/json")
644+
.body(Body::from(body.to_string()))
645+
.unwrap(),
646+
)
647+
};
648+
649+
let first = send().await.unwrap();
650+
let second = send().await.unwrap();
651+
assert_eq!(first.status(), StatusCode::OK);
652+
assert_eq!(second.status(), StatusCode::OK);
653+
654+
let parse_ids = |resp: axum::response::Response| {
655+
async move {
656+
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
657+
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
658+
json["swap_ids"]
659+
.as_array()
660+
.unwrap()
661+
.iter()
662+
.map(|id| id.as_u64().unwrap())
663+
.collect::<Vec<u64>>()
664+
}
665+
};
666+
667+
let first_ids = parse_ids(first).await;
668+
let second_ids = parse_ids(second).await;
669+
assert_eq!(first_ids.len(), 2);
670+
// #523: replay with the same key must return the cached swap IDs.
671+
assert_eq!(first_ids, second_ids);
672+
}
673+
533674
// ── #319: API Versioning tests ────────────────────────────────────────────
534675

535676
#[tokio::test]

0 commit comments

Comments
 (0)