diff --git a/migrations/012_payment_requests_memo_length_check.sql b/migrations/012_payment_requests_memo_length_check.sql new file mode 100644 index 0000000..b4982a4 --- /dev/null +++ b/migrations/012_payment_requests_memo_length_check.sql @@ -0,0 +1,13 @@ +-- Migration 012: Validate payment_requests.memo length against Stellar 28-byte limit (#49) +-- +-- Stellar's on-chain MEMO_TEXT field is hard-capped at 28 bytes by the protocol +-- (stellar_xdr::curr::Memo::Text backed by StringM<28>). A memo longer than that +-- cannot be embedded into an on-chain transaction fulfilling the request. +-- +-- Multi-byte UTF-8 characters mean length in characters != length in bytes; +-- octet_length ensures strict byte-level enforcement in PostgreSQL. +-- The column is nullable, so NULL values remain permitted. + +ALTER TABLE payment_requests +ADD CONSTRAINT check_payment_requests_memo_length +CHECK (memo IS NULL OR octet_length(memo) <= 28); diff --git a/src/services/payment_request.rs b/src/services/payment_request.rs index 2009b5d..96ee49e 100644 --- a/src/services/payment_request.rs +++ b/src/services/payment_request.rs @@ -14,10 +14,28 @@ pub struct PaymentRequestService { } impl PaymentRequestService { + /// Maximum length of Stellar MEMO_TEXT in bytes (`stellar_xdr::curr::Memo::Text` / `StringM<28>`). + pub const MAX_MEMO_TEXT_BYTES: usize = 28; + pub fn new(pool: PgPool) -> Self { Self { pool } } + /// Validate that an optional memo does not exceed Stellar's 28-byte on-chain limit. + pub fn validate_memo(memo: Option<&str>) -> AppResult<()> { + if let Some(memo) = memo { + let byte_len = memo.as_bytes().len(); + if byte_len > Self::MAX_MEMO_TEXT_BYTES { + return Err(AppError::Validation(format!( + "memo length ({} bytes) exceeds Stellar MEMO_TEXT limit of {} bytes", + byte_len, + Self::MAX_MEMO_TEXT_BYTES + ))); + } + } + Ok(()) + } + // ─── Create ─────────────────────────────────────────────────────────────── pub async fn create( @@ -35,6 +53,7 @@ impl PaymentRequestService { if req.requester_account.trim().is_empty() { return Err(AppError::Validation("requester_account is required".into())); } + Self::validate_memo(req.memo.as_deref())?; let expires_at = req .expires_in_secs @@ -169,12 +188,12 @@ impl PaymentRequestService { match request.status { PaymentRequestStatus::Pending => Ok(()), - PaymentRequestStatus::Fulfilled => { - Err(AppError::Conflict("Payment request already fulfilled".into())) - } - PaymentRequestStatus::Cancelled => { - Err(AppError::Conflict("Payment request already cancelled".into())) - } + PaymentRequestStatus::Fulfilled => Err(AppError::Conflict( + "Payment request already fulfilled".into(), + )), + PaymentRequestStatus::Cancelled => Err(AppError::Conflict( + "Payment request already cancelled".into(), + )), PaymentRequestStatus::Expired => Err(AppError::Expired("Payment request".into())), } } @@ -185,7 +204,10 @@ mod tests { use super::*; use chrono::Duration; - fn sample_request(status: PaymentRequestStatus, expires_at: Option>) -> PaymentRequest { + fn sample_request( + status: PaymentRequestStatus, + expires_at: Option>, + ) -> PaymentRequest { PaymentRequest { id: Uuid::new_v4(), requester_id: Uuid::new_v4(), @@ -208,14 +230,20 @@ mod tests { #[tokio::test] async fn pending_unexpired_request_is_actionable() { let svc_check = PaymentRequestService { pool: dummy_pool() }; - let req = sample_request(PaymentRequestStatus::Pending, Some(Utc::now() + Duration::hours(1))); + let req = sample_request( + PaymentRequestStatus::Pending, + Some(Utc::now() + Duration::hours(1)), + ); assert!(svc_check.assert_actionable(&req).is_ok()); } #[tokio::test] async fn expired_pending_request_is_rejected() { let svc_check = PaymentRequestService { pool: dummy_pool() }; - let req = sample_request(PaymentRequestStatus::Pending, Some(Utc::now() - Duration::hours(1))); + let req = sample_request( + PaymentRequestStatus::Pending, + Some(Utc::now() - Duration::hours(1)), + ); let err = svc_check.assert_actionable(&req).unwrap_err(); assert!(matches!(err, AppError::Expired(_))); } @@ -228,6 +256,59 @@ mod tests { assert!(matches!(err, AppError::Conflict(_))); } + #[test] + fn memo_at_exact_28_ascii_bytes_is_accepted() { + let memo = "1234567890123456789012345678"; + assert_eq!(memo.as_bytes().len(), 28); + assert!(PaymentRequestService::validate_memo(Some(memo)).is_ok()); + } + + #[test] + fn memo_exceeding_28_ascii_bytes_is_rejected() { + let memo = "12345678901234567890123456789"; // 29 bytes + assert_eq!(memo.as_bytes().len(), 29); + let err = PaymentRequestService::validate_memo(Some(memo)).unwrap_err(); + match err { + AppError::Validation(msg) => { + assert!(msg.contains("29 bytes")); + assert!(msg.contains("exceeds Stellar MEMO_TEXT limit of 28 bytes")); + } + other => panic!("expected AppError::Validation, got {:?}", other), + } + } + + #[test] + fn memo_multi_byte_utf8_exceeding_28_bytes_is_rejected() { + // '€' is 3 bytes in UTF-8. 10 euro symbols = 10 chars, but 30 bytes! + let multi_byte_memo = "€".repeat(10); + assert_eq!(multi_byte_memo.chars().count(), 10); + assert_eq!(multi_byte_memo.as_bytes().len(), 30); + + let err = PaymentRequestService::validate_memo(Some(&multi_byte_memo)).unwrap_err(); + match err { + AppError::Validation(msg) => { + assert!(msg.contains("30 bytes")); + assert!(msg.contains("exceeds Stellar MEMO_TEXT limit of 28 bytes")); + } + other => panic!("expected AppError::Validation, got {:?}", other), + } + } + + #[test] + fn memo_multi_byte_utf8_within_28_bytes_is_accepted() { + // '€' is 3 bytes in UTF-8. 9 euro symbols + 1 ASCII byte = 28 bytes total + let mut memo = "€".repeat(9); // 27 bytes + memo.push('A'); // + 1 byte = 28 bytes + assert_eq!(memo.as_bytes().len(), 28); + assert!(PaymentRequestService::validate_memo(Some(&memo)).is_ok()); + } + + #[test] + fn memo_none_and_empty_is_accepted() { + assert!(PaymentRequestService::validate_memo(None).is_ok()); + assert!(PaymentRequestService::validate_memo(Some("")).is_ok()); + } + /// A `PgPool` that is never actually connected to — fine because the /// functions under test here (`assert_actionable`) never touch `self.pool`. fn dummy_pool() -> PgPool {