Skip to content

EscrowService::list_for_user and SubscriptionService::list_for_user have no pagination, unlike transactions #27

Description

@abayomicornelius

EscrowService::list_for_user and SubscriptionService::list_for_user (src/services/escrow.rs, src/services/subscription.rs) both run unbounded queries with no LIMIT/pagination:

// escrow.rs
pub async fn list_for_user(&self, depositor_id: Uuid) -> AppResult<Vec<Escrow>> {
    let rows = sqlx::query_as::<_, EscrowRow>(
        "SELECT * FROM escrows WHERE depositor_id = $1 ORDER BY created_at DESC",
    ).bind(depositor_id).fetch_all(&self.pool).await?;
    ...
}

// subscription.rs — same shape, optionally filtered by status but still no LIMIT
pub async fn list_for_user(&self, payer_id: Uuid, params: &ListSubscriptionsParams) -> AppResult<Vec<Subscription>> { ... }

This is notable because the codebase clearly has pagination infrastructure elsewhere — src/commits/featpagination_add_cursor-based_.rs, src/commits/cursor_pagination.rs, src/commits/pagination_validation.rs, and src/commits/testpagination_add_pagination_in.rs (all non-functional decoy filenames under src/commits/, but indicative of real work having landed on some endpoint) — and the real GET /api/transactions endpoint (src/routes/transactions.rs) does appear to support pagination per its route comment history, while list_escrows/list_subscriptions were apparently added later without the same treatment.

Why it matters

  • A power user (or a subscription-heavy account, e.g. many recurring payments set up over months) calling GET /api/escrows or GET /api/subscriptions gets every row that user has ever created in one response, with no bound — this scales linearly (and eventually poorly) with account age/activity, unlike transactions which apparently learned this lesson already.
  • No LIMIT also means no way for a client to request "just the last 20" cheaply — every call re-fetches the full history, wasting DB and network bandwidth on every page load of an escrow/subscription list view.
  • The ListSubscriptionsParams { status: Option<SubscriptionStatus> } filter helps narrow results by status but doesn't bound the count, which is the actual unbounded-growth risk (an account could have hundreds of cancelled subscriptions and requesting status=cancelled still returns all of them at once).

Proposed fix

Edge cases

  • Cursor stability under concurrent inserts: if a new escrow is created between two paginated requests, make sure the cursor (keyed on created_at+id, not OFFSET) doesn't skip or duplicate rows — OFFSET-based pagination is well-known to have this problem and should be avoided in favor of the cursor approach the commit history suggests was already chosen for transactions.
  • status filter + pagination combined: confirm the cursor comparison still works correctly when a WHERE status = $2 clause is added conditionally (as list_for_user already does for subscriptions).

Testing strategy

  • Seed >100 escrows/subscriptions for one user, request with a small page size, and assert exactly the requested count is returned with a valid cursor for the next page.
  • Assert requesting the next page with the returned cursor returns the next set with no overlap/gaps versus a single unbounded query, using the same verification approach the existing (if only commit-decoy-referenced) transactions pagination tests presumably use.

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 | FWC26backendBackend service logicbugSomething isn't workingdatabaseDatabase/migrationsvery hardVery difficult / senior-level bounty issue

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions