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
13 changes: 13 additions & 0 deletions migrations/012_payment_requests_memo_length_check.sql
Original file line number Diff line number Diff line change
@@ -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);
99 changes: 90 additions & 9 deletions src/services/payment_request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
Expand Down Expand Up @@ -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())),
}
}
Expand All @@ -185,7 +204,10 @@ mod tests {
use super::*;
use chrono::Duration;

fn sample_request(status: PaymentRequestStatus, expires_at: Option<chrono::DateTime<Utc>>) -> PaymentRequest {
fn sample_request(
status: PaymentRequestStatus,
expires_at: Option<chrono::DateTime<Utc>>,
) -> PaymentRequest {
PaymentRequest {
id: Uuid::new_v4(),
requester_id: Uuid::new_v4(),
Expand All @@ -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(_)));
}
Expand All @@ -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 {
Expand Down