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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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