Overview
CreatePaymentRequestRequest.memo (src/models/payment_request.rs:76) is an unvalidated Option<String> — PaymentRequestService::create (src/services/payment_request.rs:23-67) never checks its length before persisting it:
pub struct CreatePaymentRequestRequest {
pub requester_account: String,
pub payer_account: Option<String>,
pub asset_code: String,
pub asset_issuer: Option<String>,
pub amount: String,
pub memo: Option<String>, // <-- no length validation anywhere
pub expires_in_secs: Option<i64>,
}
pub async fn create(&self, requester_id: Uuid, req: &CreatePaymentRequestRequest) -> AppResult<PaymentRequest> {
let amount: f64 = req.amount.parse()...;
if amount <= 0.0 { return Err(...); }
if req.requester_account.trim().is_empty() { return Err(...); }
// memo is never inspected at all
...
.bind(&req.memo)
...
}
Stellar's on-chain MEMO_TEXT field is hard-capped at 28 bytes by the protocol itself (stellar_xdr::curr::Memo::Text is backed by a length-limited StringM<28>). A payment_requests.memo longer than that is perfectly valid to store in Postgres (it's a plain TEXT column with no CHECK constraint either — see migrations/007_add_payment_requests.sql:22) but can never actually be embedded in the real on-chain transaction the payer's client eventually builds to fulfill it: the qr_payload returned by create_payment_request (routes/payment_requests.rs:33-42) echoes memo verbatim for the payer's client to prefill into its transaction-building call, and any client honoring Stellar's own protocol limit will either reject the oversized memo outright or silently truncate it — meaning the payer's on-chain transaction ends up with a memo that doesn't match what the requester actually asked for, discovered only at the point of payment, with no earlier validation error to explain why.
Multi-byte UTF-8 makes this sharper still: Stellar's 28-byte limit is a byte limit, not a character count, so a memo that looks short in a naive .len()-on-chars check (or one that's never checked at all, as here) can still exceed 28 UTF-8 bytes well before 28 visible characters if it contains any non-ASCII text.
Requirements
- Validate
memo length (in UTF-8 bytes, not chars) against Stellar's 28-byte MEMO_TEXT limit in PaymentRequestService::create, rejecting with AppError::Validation if exceeded.
- Add the equivalent
CHECK (octet_length(memo) <= 28) constraint at the database level for defense in depth, matching this codebase's general pattern of layering a DB constraint under application-level checks (e.g. subscriptions.interval_seconds's CHECK (interval_seconds > 0)).
Acceptance Criteria
Additional Notes
Edge cases
Memo::Text also technically permits raw (non-UTF-8-validated by Stellar itself, though most SDKs enforce valid UTF-8) byte strings up to 28 bytes — validating on UTF-8 byte length (str::len() in Rust, which is already byte length, not char count) is the correct check as long as the input is guaranteed valid UTF-8 by serde_json's string deserialization, which it is here.
- Consider whether the same validation belongs on any other memo-adjacent field in the codebase — a repo-wide grep confirms
payment_requests.memo is the only place a memo-shaped string field is accepted and stored server-side (SendPaymentRequest has no memo field at all; the client embeds any memo directly into the signed_xdr it builds itself for /api/payments/send, which this backend never inspects).
Testing strategy
- A straightforward unit test on
PaymentRequestService::create, following the same style as the existing amount/account validation tests already present in this service's test-adjacent sibling files (e.g. services/escrow.rs's validation tests).
Cross-references
Overview
CreatePaymentRequestRequest.memo(src/models/payment_request.rs:76) is an unvalidatedOption<String>—PaymentRequestService::create(src/services/payment_request.rs:23-67) never checks its length before persisting it:Stellar's on-chain
MEMO_TEXTfield is hard-capped at 28 bytes by the protocol itself (stellar_xdr::curr::Memo::Textis backed by a length-limitedStringM<28>). Apayment_requests.memolonger than that is perfectly valid to store in Postgres (it's a plainTEXTcolumn with noCHECKconstraint either — seemigrations/007_add_payment_requests.sql:22) but can never actually be embedded in the real on-chain transaction the payer's client eventually builds to fulfill it: theqr_payloadreturned bycreate_payment_request(routes/payment_requests.rs:33-42) echoesmemoverbatim for the payer's client to prefill into its transaction-building call, and any client honoring Stellar's own protocol limit will either reject the oversized memo outright or silently truncate it — meaning the payer's on-chain transaction ends up with a memo that doesn't match what the requester actually asked for, discovered only at the point of payment, with no earlier validation error to explain why.Multi-byte UTF-8 makes this sharper still: Stellar's 28-byte limit is a byte limit, not a character count, so a memo that looks short in a naive
.len()-on-chars check (or one that's never checked at all, as here) can still exceed 28 UTF-8 bytes well before 28 visible characters if it contains any non-ASCII text.Requirements
memolength (in UTF-8 bytes, not chars) against Stellar's 28-byteMEMO_TEXTlimit inPaymentRequestService::create, rejecting withAppError::Validationif exceeded.CHECK (octet_length(memo) <= 28)constraint at the database level for defense in depth, matching this codebase's general pattern of layering a DB constraint under application-level checks (e.g.subscriptions.interval_seconds'sCHECK (interval_seconds > 0)).Acceptance Criteria
PaymentRequestService::createrejects amemoexceeding 28 UTF-8 bytes with a clear validation error.CHECKconstraint onpayment_requests.memo.Additional Notes
Edge cases
Memo::Textalso technically permits raw (non-UTF-8-validated by Stellar itself, though most SDKs enforce valid UTF-8) byte strings up to 28 bytes — validating on UTF-8 byte length (str::len()in Rust, which is already byte length, not char count) is the correct check as long as the input is guaranteed valid UTF-8 byserde_json's string deserialization, which it is here.payment_requests.memois the only place a memo-shaped string field is accepted and stored server-side (SendPaymentRequesthas no memo field at all; the client embeds any memo directly into thesigned_xdrit builds itself for/api/payments/send, which this backend never inspects).Testing strategy
PaymentRequestService::create, following the same style as the existing amount/account validation tests already present in this service's test-adjacent sibling files (e.g.services/escrow.rs's validation tests).Cross-references
stellar_address, No numeric validation on payment amount fields (QuoteRequest.amount, SendPaymentRequest.send_amount) #17 toQuoteRequest.amount/SendPaymentRequest.send_amount— this is the first issue to flagpayment_requests.memospecifically.