Skip to content

feat(indexer): api-server v2 cursor pagination, holders and order book - #4

Merged
erubboli merged 8 commits into
masterfrom
feat/api-v2-cursor-pagination
Oct 5, 2026
Merged

erubboli merged 8 commits into
masterfrom
feat/api-v2-cursor-pagination

Conversation

@erubboli

@erubboli erubboli commented Oct 5, 2026

Copy link
Copy Markdown
Member

Summary

Brings the indexer client up to api-server 1.4.1 (mintlayer-core PR #2130, master commit 2684078c546). No breaking changes to existing method signatures — offset-based listings (ListPools, ListTransactions, per-block transaction ids, …) keep working as-is.

New endpoints & types

  • Holders: ListCoinHolders / ListTokenHolders + Holder type (address, amount). Token ids are exact case-sensitive bech32, preserved verbatim and path-escaped; unknown ids surface 404 Token not found.
  • Order book: GetOrderBook / OrderBookPager + OrderBookPage / OrderBookLevel / OrderBookPrice. Required side (WithSide), pair format {base}_{quote} validated client-side (coin ticker matches case-insensitively server-side, token ids exact). Aggregated price levels carry the exact reduced rational plus the floored decimal rendering. The documented truncation invariant is enforced client-side: Truncated == true ⇒ NextCursor == nil (server cap ORDER_BOOK_MAX_ORDERS = 10000), and the paginator stops there.
  • Statistics responses (GetCoinStatistics / GetTokenStatistics) documented: all four counters (circulating_supply, preminted, burned, staked) are always present, unwritten counters render as "0".

Cursor pagination

  • Shared CursorPage[T] envelope (items / next_cursor) and generic Pager[T] (NewPager, NextPage, Walk, NextCursor, Truncated). Walk and NextPage share one consumption position, so they can be mixed on the same pager without duplicating or dropping items. The pager stops on a nil cursor, on a truncated page, and normalizes a server-issued empty cursor to end-of-listing. Pre-1.4.1 bare-array responses are accepted and wrapped.
  • Wired up: ListPoolsPage / PoolsPager, ListTransactionsPage / TransactionsPager, both holders pagers, OrderBookPager.
  • Per-endpoint semantics documented and modeled:
    • Pools: cursor works only with the default by_height sort; pagers reject other sorts at construction, single-page methods propagate the server 400.
    • Transactions: WithOffsetMode("legacy"|"absolute") selects the offset listing (global listing is newest block first, transactions in block order per block); cursor + offset_mode is a server 400. Transaction.BlockID documented as the confirming block's hash (empty for mempool transactions). Per-block listing stays offset-based and unchanged.
    • Pools / holders / order book: a cursor silently overrides the offset page position server-side — documented, not "fixed" client-side.
    • Page stability is only guaranteed once the indexer's scanner is fully caught up; walks during catch-up/reorgs may skip or repeat entries.

Options & validation

WithItems (validated 1..=100 = MaxNumItems; the server rejects items=0 on every paginated v2 endpoint), WithOffset, WithCursor (opaque, non-empty; cursors are never constructed or mutated client-side), WithSort (whitelisted), WithOffsetMode, WithSide. Inapplicable options fail client-side with *RequestError naming the option.

Typed errors

HTTPError now carries Message and Kind parsed from the server's {"error": ...} body, with status-gated, case-insensitive classification and errors.Is support for the ErrInvalidCursor / ErrInvalidNumItems / ErrTokenNotFound sentinels.

Tests

Table-driven tests against canned JSON fixtures (indexer/testdata/): first page → full cursor walk (pools, holders, global transactions), both sides of an order book, a truncated book (iteration stops, no continuation request), last page (next_cursor: null), Walk/NextPage interleavings, empty-cursor and empty-items pages, option validation, and the exact error shapes (400 invalid cursor / invalid num items, 404 Token not found, mislabeled 5xx ⇒ ErrorKindOther).

Docs & examples

README indexer section, docs/indexer.md (cursor pagination guide, holders/order book reference, error table), CHANGELOG [Unreleased] entry, and the runnable examples/indexer-paging (coin holders walk, both-side order-book walk, global transaction listing with cursor and with offset_mode, pools walk noting the sort/cursor restriction).

Verification

  • gofmt clean, go build ./..., go vet clean on all touched packages, go test ./... green (28 files changed).
  • Reviewed with OpenCodeReview (GLM) for 8 cycles (the configured cap): every actionable finding fixed with regression tests (e.g. empty-cursor infinite-loop guard, pager construction-time validation, Walk/NextPage interleaving, zero-value Pager guard, path escaping of pair/token-id input). Two findings were rejected as contrary to the spec'd behavior (client-side 1..=100 clamping; server-400 propagation for the single-page cursor conflicts) — both are explicitly documented. Note: go vet ./wasm/ reports two pre-existing unsafe.Pointer warnings on master (unrelated to this PR; addressed by the pending wasm-fix branch).

Reviewed with OpenCodeReview

Track api-server 1.4.1 (mintlayer-core PR #2130) without breaking
existing method signatures.

New endpoints and types:
- Statistics holders listings: ListCoinHolders / ListTokenHolders plus
  the Holder type (address + amount); token ids are exact case-sensitive
  bech32 and are path-escaped.
- Order book: GetOrderBook / OrderBookPager with OrderBookPage /
  OrderBookLevel / OrderBookPrice. Required side, side-specific cursors,
  exact reduced-rational price plus floored decimal rendering, and the
  truncation invariant (Truncated true => NextCursor nil at the 10000-order
  cap; the paginator stops there).

Cursor pagination:
- Shared CursorPage[T] envelope and Pager[T] (NewPager, NextPage, Walk,
  NextCursor, Truncated). Walk and NextPage share one consumption
  position, so they can be mixed without duplicating or dropping items;
  the pager stops on a nil cursor, a truncated page, or a server-issued
  empty cursor. Pre-1.4.1 bare-array responses are accepted and wrapped.
- ListPoolsPage / PoolsPager (cursor only with the default by_height
  sort), ListTransactionsPage / TransactionsPager (WithOffsetMode,
  newest block first, BlockID documented as the confirming block hash),
  both holders pagers, OrderBookPager.

List options and validation:
- WithItems (validated 1..=100), WithOffset, WithCursor (opaque, never
  constructed client-side), WithSort (whitelisted), WithOffsetMode,
  WithSide. Inapplicable options fail client-side with *RequestError
  naming the option; pagers validate at construction.
- Documented semantics: page stability only once the scanner is caught
  up; a cursor silently overrides the offset position server-side on
  pools/holders/order book; cursor + non-default sort (pools) and
  cursor + offset_mode (transactions) are server 400s, propagated.

Typed errors:
- HTTPError.Message/Kind populated from the server error body;
  ErrorKind classification status-gated and case-insensitive;
  ErrInvalidCursor / ErrInvalidNumItems / ErrTokenNotFound sentinels
  matched via errors.Is.

Docs: README, docs/indexer.md (cursor pagination guide, holders and
order book reference, error table), CHANGELOG, and the runnable
examples/indexer-paging. Table-driven tests against canned fixtures
cover full walks, both book sides, truncated and last pages, option
validation, and every error shape.
@github-actions

github-actions Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

✅ OpenCodeReview: Review complete: 0 finding(s) across 10 selected item(s).

Comment thread indexer/statistics.go
Comment on lines +129 to +137
func TokenHoldersPager(c *Client, tokenID string, opts ...ListOption) *Pager[Holder] {
p, err := applyListOptions(opts)
if err == nil {
err = p.reject("TokenHoldersPager", paramSide|paramSort|paramOffsetMode)
}
if err == nil && tokenID == "" {
err = &RequestError{Option: "tokenID", Reason: "must not be empty"}
}
return NewPager(func(ctx context.Context, cursor *string) ([]Holder, *string, bool, error) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

bug · critical
If applyListOptions fails, p is nil, but the error is only captured and the function still constructs the pager with a valid fetch closure instead of returning failHolder like the sibling constructors (CoinHoldersPager, PoolsPager, TransactionsPager, OrderBookPager). On first use, NextPage calls the closure with p == nil, and fetchTokenHolders's p.reject dereferences the nil *listParams, panicking. Even when tokenID is empty (err set, p non-nil), the deferred *RequestError contract is silently lost and a doomed network request is issued instead. Return failHolder when err != nil.

Suggestion:

Suggested change
func TokenHoldersPager(c *Client, tokenID string, opts ...ListOption) *Pager[Holder] {
p, err := applyListOptions(opts)
if err == nil {
err = p.reject("TokenHoldersPager", paramSide|paramSort|paramOffsetMode)
}
if err == nil && tokenID == "" {
err = &RequestError{Option: "tokenID", Reason: "must not be empty"}
}
return NewPager(func(ctx context.Context, cursor *string) ([]Holder, *string, bool, error) {
if err != nil {
return fail[Holder](err)
}
return NewPager(func(ctx context.Context, cursor *string) ([]Holder, *string, bool, error) {

Comment thread indexer/transaction.go
Comment on lines +40 to +43
if err := p.reject("ListTransactionsPage", paramSide|paramSort); err != nil {
return nil, nil, false, err
}
page, err := getPage[Transaction](ctx, c, "/transaction", transactionsQuery(p))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

bug · medium
The documented contract says combining WithCursor with WithOffsetMode is rejected by the server with 400 "Bad request", but fetchTransactions only rejects paramSide|paramOffsetMode — a cursor+offset_mode combination passes client-side validation and incurs a guaranteed round-trip 400. The ListOption layer's stated goal is to surface misuse as *RequestError before network traffic; consider rejecting paramOffsetMode when p.set&paramCursor != 0 (and paramCursor when offset mode is set) here.

Suggestion:

Suggested change
if err := p.reject("ListTransactionsPage", paramSide|paramSort); err != nil {
return nil, nil, false, err
}
page, err := getPage[Transaction](ctx, c, "/transaction", transactionsQuery(p))
if err := p.reject("ListTransactionsPage", paramSide|paramSort); err != nil {
return nil, nil, false, err
}
if p.set&(paramCursor|paramOffsetMode) == (paramCursor | paramOffsetMode) {
return nil, nil, false, &RequestError{Option: "WithOffsetMode", Reason: "cannot be combined with WithCursor"}
}
page, err := getPage[Transaction](ctx, c, "/transaction", transactionsQuery(p))

- TokenHoldersPager: return fail on constructor error instead of capturing
  a nil params pointer (nil-deref panic on the first NextPage when an
  option like WithItems(0) was rejected)
- ListTransactionsPage: reject cursor + offset_mode client-side with a
  RequestError instead of a guaranteed server 400 round trip; docs and
  changelog updated to match
- getPage: trim whitespace before the bare-array sniff so a legacy offset
  response with leading whitespace decodes as a page instead of a
  silent empty envelope
Comment thread indexer/paginate.go
Comment on lines +172 to +177
func (p *Pager[T]) NextCursor() *string {
if !p.started {
return nil
}
return p.cursor
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

bug · medium
NextCursor returns the pager's internal cursor pointer directly. A caller that mutates the pointed-to string (or mutates the *string returned in CursorPage.NextCursor by the *Page methods) silently corrupts the pager's resume position and any value the caller persists, despite the documented guarantee that the value is "never constructed or modified client-side". Return a defensive copy of the value instead.

Suggestion:

Suggested change
func (p *Pager[T]) NextCursor() *string {
if !p.started {
return nil
}
return p.cursor
}
func (p *Pager[T]) NextCursor() *string {
if !p.started || p.cursor == nil {
return nil
}
v := *p.cursor
return &v
}

A caller mutating the pointed-to string previously corrupted the
pager's resume position; the value is now copied before it leaves the
pager.
- every well-known ErrorKind now has an errors.Is sentinel
  (adds ErrBadRequest, ErrInvalidOffsetMode, ErrInvalidPoolsSortOrder,
  ErrInvalidTokenID, ErrInvalidOrderPair); docs tables updated
- Pager.NextPage documents that the returned slice aliases the pager's
  internal page buffer and must not be modified
PoolsPager rejects non-default sorts client-side with a RequestError;
the server 400 only applies to ListPoolsPage with an explicit cursor.
Comment thread indexer/pool.go
Comment on lines +46 to +50
func (c *Client) fetchPools(ctx context.Context, p *listParams) ([]Pool, *string, bool, error) {
if err := p.reject("ListPoolsPage", paramSide|paramOffsetMode); err != nil {
return nil, nil, false, err
}
page, err := getPage[Pool](ctx, c, "/pool", poolsQuery(p))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

maintainability · medium
Validation inconsistency between the two public entry points for the same listing: PoolsPager rejects a non-default sort up front with a *RequestError, but ListPoolsPage/fetchPools perform no equivalent check, so WithCursor combined with WithSort(SortByPledge) reaches the wire and only fails at request time with HTTP 400 "Bad request". This breaks the documented ListOption contract that unsupported combinations "fail the request with a *RequestError before any network traffic". Consider mirroring the pager-side check in fetchPools (e.g. reject paramCursor+paramSort when sort != SortByHeight), or explicitly document that only the pager validates this combination.

Suggestion:

Suggested change
func (c *Client) fetchPools(ctx context.Context, p *listParams) ([]Pool, *string, bool, error) {
if err := p.reject("ListPoolsPage", paramSide|paramOffsetMode); err != nil {
return nil, nil, false, err
}
page, err := getPage[Pool](ctx, c, "/pool", poolsQuery(p))
func (c *Client) fetchPools(ctx context.Context, p *listParams) ([]Pool, *string, bool, error) {
if err := p.reject("ListPoolsPage", paramSide|paramOffsetMode); err != nil {
return nil, nil, false, err
}
if p.set&paramCursor != 0 && p.sort != "" && p.sort != SortByHeight {
return nil, nil, false, &RequestError{Option: "WithSort", Reason: "cursor pagination requires the default by_height sort"}
}
page, err := getPage[Pool](ctx, c, "/pool", poolsQuery(p))

…example

- document that ListPoolsPage propagates the server 400 for
  cursor + non-default sort (api-server contract) while PoolsPager
  validates the combination client-side
- example walks now return errors and main exits non-zero on failure so
  the demo doubles as a CI-observable smoke check
The api-server issues next_cursor only alongside items (it fetches
items+1 to detect the next page), so an empty page cannot legitimately
continue. Treating an empty fetched page as terminal bounds Walk against
a non-compliant peer that returns an empty page with a non-null cursor
forever. TestPagerNilItemsWithCursor pins the new contract.
@erubboli
erubboli merged commit 546f9ea into master Oct 5, 2026
1 check passed
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.

1 participant