Skip to content

Add Relay-style cursor pagination for account balances queries#577

Merged
aditya1702 merged 9 commits intofeature/balance-from-account-queryfrom
feature/balances-pagination
Apr 16, 2026
Merged

Add Relay-style cursor pagination for account balances queries#577
aditya1702 merged 9 commits intofeature/balance-from-account-queryfrom
feature/balances-pagination

Conversation

@aditya1702
Copy link
Copy Markdown
Contributor

@aditya1702 aditya1702 commented Apr 15, 2026

Closes #576

What

Converts Account.balances from a flat list ([Balance!]) to a Relay cursor connection (BalanceConnection!) with first/after/last/before pagination, and adds keyset pagination support to every backing data source.

Schema

  • Account.balances changed from [Balance!] to balances(first, after, last, before): BalanceConnection!
  • Added BalanceConnection and BalanceEdge types in pagination.graphqls

Resolver layer (account_balances.go: +530 lines)

  • Multi-source pagination engine that merges native XLM, classic trustlines, SAC, and SEP-41 balances into one Relay connection
  • Source-aware opaque cursors (v1:<source>:<id>) so pagination can resume across heterogeneous balance types
  • Forward and backward source walkers that skip fully-consumed sources and apply the keyset cursor only to the source that owns the page boundary
  • SEP-41 sentinel optimization: the extra "+1" row for hasNextPage is carried as a cursor-only node to avoid a wasted RPC call

Data layer

  • TrustlineBalanceModel.GetByAccount — now accepts limit, cursor (asset UUID), and sortOrder for keyset pagination
  • SACBalanceModel.GetByAccount — same keyset pagination parameters, cursoring by contract UUID
  • New: AccountContractTokensModel.GetSEP41ByAccount — paginated query for SEP-41 contracts by junction-table UUID
  • Updated mocks and added pagination tests for all three models

Shared pagination infrastructure (utils.go)

  • Added CursorTypeString cursor type and StringCursor field on PaginationParams
  • NewConnectionWithRelayPagination now considers StringCursor when computing hasCursor

Wiring

  • balanceReaderAdapter updated to pass pagination params through to the data models
  • BalanceReader interface signatures widened with limit/cursor/sortOrder
  • Complexity function registered for Account.balances
  • pkg/wbclient updated to query the new edges { node { ... } } shape

Why

The parent branch moved balances onto Account but without pagination

feature/balance-from-account-query relocated balance fetching from root queries to Account.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.

Copilot AI review requested due to automatic review settings April 15, 2026 20:26
@aditya1702 aditya1702 changed the base branch from main to feature/balance-from-account-query April 15, 2026 20:26
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.balances from returning a list to returning a Relay BalanceConnection with 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 sac source for contract (C...) addresses (balanceSourcesForAddress in internal/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.

Comment thread internal/serve/graphql/schema/account.graphqls
Comment thread internal/serve/graphql/README.md Outdated
Comment thread internal/serve/graphql/README.md Outdated
Comment thread internal/serve/graphql/schema/account.graphqls
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.
@aristidesstaffieri
Copy link
Copy Markdown
Contributor

Code review

Found 1 issue:

  1. README says G-addresses return SAC balances, but the resolver excludes SAC for G-addresses (only C-addresses route through balanceSourceSAC).

README claim:

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

Actual behavior in balanceSourcesForAddress:

// balanceSourcesForAddress returns the ordered set of balance sources that can
// apply to the requested address type.
//
// G-addresses can have native XLM, classic trustlines, and SEP-41 balances.
// C-addresses can hold SAC balances and SEP-41 balances, but never native/classic rows.
func balanceSourcesForAddress(address string) []balanceSource {
if utils.IsContractAddress(address) {
return []balanceSource{balanceSourceSAC, balanceSourceSEP41}
}
return []balanceSource{balanceSourceNative, balanceSourceClassic, balanceSourceSEP41}
}

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 👎.

@aristidesstaffieri
Copy link
Copy Markdown
Contributor

Code review

Found 1 issue:

  1. README says G-addresses return SAC balances, but the resolver excludes SAC for G-addresses (only C-addresses route through balanceSourceSAC).

README claim:

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

Actual behavior in balanceSourcesForAddress:

// balanceSourcesForAddress returns the ordered set of balance sources that can
// apply to the requested address type.
//
// G-addresses can have native XLM, classic trustlines, and SEP-41 balances.
// C-addresses can hold SAC balances and SEP-41 balances, but never native/classic rows.
func balanceSourcesForAddress(address string) []balanceSource {
if utils.IsContractAddress(address) {
return []balanceSource{balanceSourceSAC, balanceSourceSEP41}
}
return []balanceSource{balanceSourceNative, balanceSourceClassic, balanceSourceSEP41}
}

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.

@aristidesstaffieri
Copy link
Copy Markdown
Contributor

Follow-up (non-blocking, not in scope for this PR)

With Account.balances now weighted by paginatedQueryComplexityFunc, the remaining unweighted amplification hop in nested queries is Transaction.accounts / Operation.accounts — both declared as [Account!]! with no complexity function and no LIMIT in the underlying query.

Schema —

# Related accounts - uses resolver with dataloader for efficiency
accounts: [Account!]! @goField(forceResolver: true)
# Related state changes - uses resolver to fetch associated changes

Resolver (dataloader with no cap) —

}
// Accounts is the resolver for the accounts field.
// This is a field resolver for the "accounts" field on a Transaction object
// It's called when a GraphQL query requests the accounts within a transaction
func (r *transactionResolver) Accounts(ctx context.Context, obj *types.Transaction) ([]*types.Account, error) {
loaders := ctx.Value(middleware.LoadersKey).(*dataloaders.Dataloaders)
dbColumns := GetDBColumnsForFields(ctx, types.Account{})
// Use dataloader to batch-load accounts for this transaction
// This prevents N+1 queries when multiple transactions request their operations
// The loader groups multiple requests and executes them in a single database query
loaderKey := dataloaders.AccountColumnsKey{
ToID: obj.ToID,
Columns: strings.Join(dbColumns, ", "),
}
accounts, err := loaders.AccountsByToIDLoader.Load(ctx, loaderKey)
if err != nil {
return nil, err
}
return accounts, nil
}

Data layer (no LIMIT) — https://github.com/stellar/wallet-backend/blob/0b19f6671bf3761e3e122b44a78b60e3b6098964/internal/data/accounts.go#L38-L76

Complexity registrations (note: Transaction.Accounts / Operation.Accounts are not registered) —

return calculatePaginatedComplexity(childComplexity, first, last)
}
config.Complexity.Query.Transactions = paginatedQueryComplexityFunc
config.Complexity.Query.Operations = paginatedQueryComplexityFunc
config.Complexity.Query.StateChanges = paginatedQueryComplexityFunc
config.Complexity.Account.Balances = paginatedQueryComplexityFunc
config.Complexity.Account.Transactions = func(childComplexity int, since *time.Time, until *time.Time, first *int32, after *string, last *int32, before *string) int {
return calculatePaginatedComplexity(childComplexity, first, last)
}
config.Complexity.Account.Operations = func(childComplexity int, since *time.Time, until *time.Time, first *int32, after *string, last *int32, before *string) int {
return calculatePaginatedComplexity(childComplexity, first, last)
}
config.Complexity.Account.StateChanges = func(childComplexity int, filter *generated.AccountStateChangeFilterInput, since *time.Time, until *time.Time, first *int32, after *string, last *int32, before *string) int {
return calculatePaginatedComplexity(childComplexity, first, last)
}
config.Complexity.Transaction.Operations = paginatedQueryComplexityFunc
config.Complexity.Transaction.StateChanges = paginatedQueryComplexityFunc
config.Complexity.Operation.StateChanges = paginatedQueryComplexityFunc
}

Concretely: a query like transactions(first: 30){ edges{ node{ accounts{ balances(first: 50){...} } } } } scores roughly 30 × (1 + 50 × leafCost) statically (well under the 5000 budget), but runtime fan-out is 30 × actual_accounts_per_tx × 50. Bounded in practice by Stellar protocol constraints (signers per tx, Soroban auth entries), but the bound isn't enforced at the GraphQL layer — if a future op type carries many participants, the amplification path re-opens. Pre-existing in main, not introduced by this PR, but worth tracking as a follow-up.

🤖 Generated with Claude Code

@aditya1702 aditya1702 merged commit 5be70c2 into feature/balance-from-account-query Apr 16, 2026
7 checks passed
@aditya1702 aditya1702 deleted the feature/balances-pagination branch April 16, 2026 20:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants