Add Relay-style cursor pagination for account balances queries#577
Conversation
There was a problem hiding this comment.
Pull request overview
This PR introduces Relay-style cursor pagination for Account.balances, updating both the GraphQL schema/resolver and the Go client to page through balances via edges/pageInfo.
Changes:
- Switch
Account.balancesfrom returning a list to returning a RelayBalanceConnectionwith cursor pagination args. - Implement multi-source keyset pagination in the balances resolver (native, trustlines, SAC, SEP-41), including source-aware opaque cursors.
- Add paginated data-layer queries (trustlines, SAC, SEP-41 contract memberships) plus updated tests and client-side pagination.
Reviewed changes
Copilot reviewed 21 out of 23 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| pkg/wbclient/queries.go | Updates account balances query to request balances(first, after) as a connection. |
| pkg/wbclient/client.go | Updates unmarshalling for balances connection and iterates pages until exhausted. |
| internal/serve/serve.go | Hooks Account.balances into shared pagination complexity calculation. |
| internal/serve/graphql/schema/pagination.graphqls | Adds BalanceConnection / BalanceEdge types. |
| internal/serve/graphql/schema/account.graphqls | Changes balances field signature to Relay pagination and connection return type. |
| internal/serve/graphql/resolvers/utils.go | Adds string-cursor support to shared pagination helpers. |
| internal/serve/graphql/resolvers/resolver.go | Extends BalanceReader interface with paginated balance methods. |
| internal/serve/graphql/resolvers/balance_reader.go | Implements adapter methods for paginated trustline/SAC reads. |
| internal/serve/graphql/resolvers/account_balances_utils.go | Refactors SEP-41 balance fetch to single-contract helper for paginated flow. |
| internal/serve/graphql/resolvers/account_balances.go | Implements multi-source Relay pagination + cursor encoding/decoding for balances. |
| internal/serve/graphql/resolvers/account.resolvers.go | Updates Account.balances resolver signature to return BalanceConnection. |
| internal/serve/graphql/resolvers/account_balances_test.go | Rewrites tests to validate connection semantics and cross-source pagination behavior. |
| internal/serve/graphql/generated/models_gen.go | Regenerates models for BalanceConnection / BalanceEdge. |
| internal/serve/graphql/generated/generated.go | Regenerates schema execution code for new balances field signature/types. |
| internal/serve/graphql/README.md | Updates documentation/examples to use balances connection shape. |
| internal/serve/complexity_test.go | Adds complexity test coverage for balances default page sizing and explicit first. |
| internal/data/trustline_balances.go | Adds keyset-paginated trustline balance query. |
| internal/data/trustline_balances_test.go | Adds tests for trustline keyset pagination ordering/cursor behavior. |
| internal/data/sac_balances.go | Adds keyset-paginated SAC balance query. |
| internal/data/sac_balances_test.go | Adds tests for SAC keyset pagination ordering/cursor behavior. |
| internal/data/account_contract_tokens.go | Adds keyset-paginated query for SEP-41 memberships. |
| internal/data/account_contract_tokens_test.go | Adds tests for SEP-41 membership keyset pagination. |
| internal/data/mocks.go | Extends mocks to support new paginated model methods. |
Comments suppressed due to low confidence (1)
internal/serve/graphql/README.md:632
- The balances docs state that G-addresses return SAC balances and that the resolver reads SAC balances from PostgreSQL, but the new resolver only includes the
sacsource for contract (C...) addresses (balanceSourcesForAddressininternal/serve/graphql/resolvers/account_balances.go). Please update the README to match the actual behavior (e.g., clarify that SACBalance nodes are only returned for C-addresses, and what clients should expect for G-addresses).
This query uses keyset pagination over the balance backing tables:
1. Reads native, trustline, and SAC balances from PostgreSQL in a stable source order
2. Reads SEP-41 contract memberships from PostgreSQL using the same cursor order
3. Fetches SEP-41 `balance(address)` values only for contracts in the returned page
4. Builds Relay `edges` and `pageInfo` so clients can continue paging with opaque cursors
**Supported Address Types:**
- **G-addresses**: Returns native XLM, trustlines, SAC, and SEP-41 balances
- **C-addresses** (contract addresses): Returns SAC and SEP-41 balances only
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Rename GetSEP41ByAccountPaginated to GetSEP41ByAccount across the codebase (interface, model implementation, mocks, GraphQL resolver, and tests). Update metrics label names to use the new method identifier. Increase the GraphQL complexity limit default from 1000 to 5000 and update the integration test container env var to match (was 2000 -> 5000). Update tests to call the renamed method.
Code reviewFound 1 issue:
README claim: wallet-backend/internal/serve/graphql/README.md Lines 622 to 632 in 0b19f66 Actual behavior in wallet-backend/internal/serve/graphql/resolvers/account_balances.go Lines 96 to 108 in 0b19f66 Lines 624 ("Reads native, trustline, and SAC balances from PostgreSQL") and 630 ("G-addresses: Returns native XLM, trustlines, SAC, and SEP-41 balances") both contradict the implementation, which only returns `{native, classic, sep41}` for G-addresses. Copilot's review also flagged this as a suppressed low-confidence comment; worth either adjusting the docs or the resolver so they agree. 🤖 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
this is incorrect, done through trustlines. This can be ignored. |
Follow-up (non-blocking, not in scope for this PR)With Schema — wallet-backend/internal/serve/graphql/schema/transaction.graphqls Lines 16 to 20 in 0b19f66 Resolver (dataloader with no cap) — Data layer (no LIMIT) — https://github.com/stellar/wallet-backend/blob/0b19f6671bf3761e3e122b44a78b60e3b6098964/internal/data/accounts.go#L38-L76 Complexity registrations (note: wallet-backend/internal/serve/serve.go Lines 372 to 390 in 0b19f66 Concretely: a query like 🤖 Generated with Claude Code |
5be70c2
into
feature/balance-from-account-query
Closes #576
What
Converts
Account.balancesfrom a flat list ([Balance!]) to a Relay cursor connection (BalanceConnection!) withfirst/after/last/beforepagination, and adds keyset pagination support to every backing data source.Schema
Account.balanceschanged from[Balance!]tobalances(first, after, last, before): BalanceConnection!BalanceConnectionandBalanceEdgetypes inpagination.graphqlsResolver layer (
account_balances.go: +530 lines)v1:<source>:<id>) so pagination can resume across heterogeneous balance typeshasNextPageis carried as a cursor-only node to avoid a wasted RPC callData layer
TrustlineBalanceModel.GetByAccount— now acceptslimit,cursor(asset UUID), andsortOrderfor keyset paginationSACBalanceModel.GetByAccount— same keyset pagination parameters, cursoring by contract UUIDAccountContractTokensModel.GetSEP41ByAccount— paginated query for SEP-41 contracts by junction-table UUIDShared pagination infrastructure (
utils.go)CursorTypeStringcursor type andStringCursorfield onPaginationParamsNewConnectionWithRelayPaginationnow considersStringCursorwhen computinghasCursorWiring
balanceReaderAdapterupdated to pass pagination params through to the data modelsBalanceReaderinterface signatures widened withlimit/cursor/sortOrderAccount.balancespkg/wbclientupdated to query the newedges { node { ... } }shapeWhy
The parent branch moved balances onto
Accountbut without paginationfeature/balance-from-account-queryrelocated balance fetching from root queries toAccount.balances, but returned a flat[Balance!]list. This meant every query still loaded all balances for an account in one shot — unbounded response size and unbounded RPC fan-out for SEP-41 contracts.Accounts can have many balances
A single Stellar account can hold dozens of trustlines and tracked contract tokens. Without pagination, a single
account { balances }query could trigger O(n) database reads and O(n) RPC calls with no client-controlled limit, making query cost unpredictable for both clients and the server.Consistency with the rest of the API
Transactions, operations, and state changes already use Relay cursor connections. Balances were the only paginated-shape field still returning a flat list. This PR brings balances in line with the existing pagination contract so clients have a uniform querying pattern.
RPC cost control for SEP-41
The sentinel node optimization avoids fetching a balance from RPC for the extra "+1" row used to detect
hasNextPage. Since SEP-41 is the only source that needs RPC work per row, this saves one RPC round-trip per page when SEP-41 contracts are the trailing source.