From 487c6b098ddf2a4f31fc0c2947f176162606b271 Mon Sep 17 00:00:00 2001 From: ghzhost Date: Wed, 2 Sep 2026 21:31:58 +0000 Subject: [PATCH] fix(payment-requests): validate memo length against Stellar 28-byte limit (#49) --- .../012_add_payment_request_memo_check.sql | 10 +++ src/services/payment_request.rs | 83 +++++++++++++++++-- 2 files changed, 86 insertions(+), 7 deletions(-) create mode 100644 migrations/012_add_payment_request_memo_check.sql diff --git a/migrations/012_add_payment_request_memo_check.sql b/migrations/012_add_payment_request_memo_check.sql new file mode 100644 index 0000000..5f6f275 --- /dev/null +++ b/migrations/012_add_payment_request_memo_check.sql @@ -0,0 +1,10 @@ +-- Migration 012: Add byte length constraint to payment_requests.memo (#49) +-- +-- Stellar's on-chain MEMO_TEXT field is hard-capped at 28 bytes by the protocol +-- (stellar_xdr Memo::Text is backed by StringM<28>). +-- This migration adds a CHECK constraint ensuring payment_requests.memo never +-- exceeds 28 UTF-8 bytes (octet_length) in the database. + +ALTER TABLE payment_requests + ADD CONSTRAINT check_payment_requests_memo_max_bytes + 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..85a7cf1 100644 --- a/src/services/payment_request.rs +++ b/src/services/payment_request.rs @@ -9,6 +9,8 @@ use chrono::{Duration as ChronoDuration, Utc}; use sqlx::PgPool; use uuid::Uuid; +pub const MAX_MEMO_BYTES: usize = 28; + pub struct PaymentRequestService { pool: PgPool, } @@ -18,13 +20,8 @@ impl PaymentRequestService { Self { pool } } - // ─── Create ─────────────────────────────────────────────────────────────── - - pub async fn create( - &self, - requester_id: Uuid, - req: &CreatePaymentRequestRequest, - ) -> AppResult { + /// Validates the request fields prior to DB insertion. + pub fn validate_create_request(req: &CreatePaymentRequestRequest) -> AppResult<()> { let amount: f64 = req .amount .parse() @@ -35,6 +32,25 @@ impl PaymentRequestService { if req.requester_account.trim().is_empty() { return Err(AppError::Validation("requester_account is required".into())); } + if let Some(memo) = &req.memo { + if memo.len() > MAX_MEMO_BYTES { + return Err(AppError::Validation(format!( + "memo exceeds Stellar's 28-byte limit (got {} bytes)", + memo.len() + ))); + } + } + Ok(()) + } + + // ─── Create ─────────────────────────────────────────────────────────────── + + pub async fn create( + &self, + requester_id: Uuid, + req: &CreatePaymentRequestRequest, + ) -> AppResult { + Self::validate_create_request(req)?; let expires_at = req .expires_in_secs @@ -205,6 +221,59 @@ mod tests { } } + fn sample_create_req(amount: &str, requester: &str, memo: Option<&str>) -> CreatePaymentRequestRequest { + CreatePaymentRequestRequest { + requester_account: requester.to_string(), + payer_account: None, + asset_code: "XLM".to_string(), + asset_issuer: None, + amount: amount.to_string(), + memo: memo.map(|s| s.to_string()), + expires_in_secs: None, + } + } + + #[test] + fn test_memo_length_validation() { + // Valid: no memo + let req = sample_create_req("10.0", "GREQUESTER", None); + assert!(PaymentRequestService::validate_create_request(&req).is_ok()); + + // Valid: empty memo + let req = sample_create_req("10.0", "GREQUESTER", Some("")); + assert!(PaymentRequestService::validate_create_request(&req).is_ok()); + + // Valid: exactly 28 ASCII bytes + let memo_28_ascii = "1234567890123456789012345678"; + assert_eq!(memo_28_ascii.len(), 28); + let req = sample_create_req("10.0", "GREQUESTER", Some(memo_28_ascii)); + assert!(PaymentRequestService::validate_create_request(&req).is_ok()); + + // Invalid: 29 ASCII bytes + let memo_29_ascii = "12345678901234567890123456789"; + assert_eq!(memo_29_ascii.len(), 29); + let req = sample_create_req("10.0", "GREQUESTER", Some(memo_29_ascii)); + let err = PaymentRequestService::validate_create_request(&req).unwrap_err(); + assert!(matches!(err, AppError::Validation(_))); + + // Multi-byte UTF-8 test: + // "🦀" is 1 char but 4 UTF-8 bytes. + // 7 crabs = 7 chars, exactly 28 bytes -> valid. + let memo_7_crabs = "🦀🦀🦀🦀🦀🦀🦀"; + assert_eq!(memo_7_crabs.chars().count(), 7); + assert_eq!(memo_7_crabs.len(), 28); + let req = sample_create_req("10.0", "GREQUESTER", Some(memo_7_crabs)); + assert!(PaymentRequestService::validate_create_request(&req).is_ok()); + + // 8 crabs = 8 chars, 32 bytes (> 28 bytes) -> must be rejected even though chars.count() is only 8 + let memo_8_crabs = "🦀🦀🦀🦀🦀🦀🦀🦀"; + assert_eq!(memo_8_crabs.chars().count(), 8); + assert_eq!(memo_8_crabs.len(), 32); + let req = sample_create_req("10.0", "GREQUESTER", Some(memo_8_crabs)); + let err = PaymentRequestService::validate_create_request(&req).unwrap_err(); + assert!(matches!(err, AppError::Validation(_))); + } + #[tokio::test] async fn pending_unexpired_request_is_actionable() { let svc_check = PaymentRequestService { pool: dummy_pool() };