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.
EscrowService::list_for_userandSubscriptionService::list_for_user(src/services/escrow.rs,src/services/subscription.rs) both run unbounded queries with noLIMIT/pagination: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, andsrc/commits/testpagination_add_pagination_in.rs(all non-functional decoy filenames undersrc/commits/, but indicative of real work having landed on some endpoint) — and the realGET /api/transactionsendpoint (src/routes/transactions.rs) does appear to support pagination per its route comment history, whilelist_escrows/list_subscriptionswere apparently added later without the same treatment.Why it matters
GET /api/escrowsorGET /api/subscriptionsgets every row that user has ever created in one response, with no bound — this scales linearly (and eventually poorly) with account age/activity, unliketransactionswhich apparently learned this lesson already.LIMITalso 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.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 ofcancelledsubscriptions and requestingstatus=cancelledstill returns all of them at once).Proposed fix
/api/transactions(per the commit-name evidence) tolist_for_userin bothEscrowServiceandSubscriptionService— likely a(created_at, id)composite cursor given the existingORDER BY created_at DESC, with aLIMIT(e.g. default 20, max 100) and abefore/aftercursor query param onGET /api/escrowsandGET /api/subscriptions.fn apply_cursor_pagination(...)) rather than hand-rolling the cursor SQL twice — same "shared helper vs. duplicated logic" theme as issues Registration accepts unvalidated stellar_address and lacks structured input validation #16/No numeric validation on payment amount fields (QuoteRequest.amount, SendPaymentRequest.send_amount) #17's validation-extraction recommendation.Edge cases
created_at+id, notOFFSET) 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.statusfilter + pagination combined: confirm the cursor comparison still works correctly when aWHERE status = $2clause is added conditionally (aslist_for_useralready does for subscriptions).Testing strategy