Skip to content

payment_requests.memo has no length validation against Stellar's 28-byte on-chain MEMO_TEXT limit #49

Description

@abayomicornelius

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

  • PaymentRequestService::create rejects a memo exceeding 28 UTF-8 bytes with a clear validation error.
  • A migration adds the matching CHECK constraint on payment_requests.memo.
  • A test covers the multi-byte-UTF-8 boundary specifically — e.g. a memo of exactly 28 ASCII bytes (accepted), 29 ASCII bytes (rejected), and a shorter-in-characters-but-longer-in-bytes multi-byte string that exceeds 28 bytes (rejected) — to prove the check is byte-based, not character-based.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    GrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardOfficial Campaign | FWC26Campaign: Official Campaign | FWC26Third CampaignCampaign: Third CampaignbackendBackend service logicbugSomething isn't workingvery hardVery difficult / senior-level bounty issue

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions