Skip to content
Open
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
10 changes: 10 additions & 0 deletions migrations/012_add_payment_request_memo_check.sql
Original file line number Diff line number Diff line change
@@ -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);
83 changes: 76 additions & 7 deletions src/services/payment_request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand All @@ -18,13 +20,8 @@ impl PaymentRequestService {
Self { pool }
}

// ─── Create ───────────────────────────────────────────────────────────────

pub async fn create(
&self,
requester_id: Uuid,
req: &CreatePaymentRequestRequest,
) -> AppResult<PaymentRequest> {
/// Validates the request fields prior to DB insertion.
pub fn validate_create_request(req: &CreatePaymentRequestRequest) -> AppResult<()> {
let amount: f64 = req
.amount
.parse()
Expand All @@ -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<PaymentRequest> {
Self::validate_create_request(req)?;

let expires_at = req
.expires_in_secs
Expand Down Expand Up @@ -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() };
Expand Down