From e51a3d909ab5d43fd26d36a34cf4171c9a0aaf48 Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Mon, 5 Oct 2026 14:15:36 +0200 Subject: [PATCH 1/8] feat(indexer): api-server v2 cursor pagination, holders and order book 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. --- CHANGELOG.md | 18 + README.md | 16 +- docs/indexer.md | 220 +++- examples/indexer-paging/main.go | 220 ++++ indexer/client.go | 19 +- indexer/errors.go | 134 +++ indexer/options.go | 239 +++++ indexer/order.go | 135 +++ indexer/paginate.go | 226 +++++ indexer/pagination_test.go | 947 ++++++++++++++++++ indexer/pool.go | 76 ++ indexer/statistics.go | 98 ++ indexer/testdata/book_asks_page1.json | 7 + indexer/testdata/book_asks_page2.json | 6 + indexer/testdata/book_asks_truncated.json | 7 + indexer/testdata/book_bids_page1.json | 7 + indexer/testdata/holders_page1.json | 7 + indexer/testdata/holders_page2.json | 6 + .../testdata/holders_page_empty_cursor.json | 9 + .../testdata/holders_page_empty_items.json | 4 + indexer/testdata/pools_offset.json | 11 + indexer/testdata/pools_page1.json | 23 + indexer/testdata/pools_page2.json | 14 + indexer/testdata/transactions_offset.json | 10 + indexer/testdata/transactions_page1.json | 21 + indexer/testdata/transactions_page2.json | 13 + indexer/transaction.go | 70 ++ indexer/types.go | 69 +- 28 files changed, 2617 insertions(+), 15 deletions(-) create mode 100644 examples/indexer-paging/main.go create mode 100644 indexer/errors.go create mode 100644 indexer/options.go create mode 100644 indexer/paginate.go create mode 100644 indexer/pagination_test.go create mode 100644 indexer/testdata/book_asks_page1.json create mode 100644 indexer/testdata/book_asks_page2.json create mode 100644 indexer/testdata/book_asks_truncated.json create mode 100644 indexer/testdata/book_bids_page1.json create mode 100644 indexer/testdata/holders_page1.json create mode 100644 indexer/testdata/holders_page2.json create mode 100644 indexer/testdata/holders_page_empty_cursor.json create mode 100644 indexer/testdata/holders_page_empty_items.json create mode 100644 indexer/testdata/pools_offset.json create mode 100644 indexer/testdata/pools_page1.json create mode 100644 indexer/testdata/pools_page2.json create mode 100644 indexer/testdata/transactions_offset.json create mode 100644 indexer/testdata/transactions_page1.json create mode 100644 indexer/testdata/transactions_page2.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d28476..02925c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,24 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +### Added + +#### Indexer client (`github.com/mintlayer/go-sdk/indexer`) + +Cursor pagination for the api-server v2 endpoints introduced in mintlayer-core PR #2130 (api-server 1.4.1): + +- **Pools**: `ListPoolsPage` and `PoolsPager` — cursor envelope over `/v2/pool`; cursors work only with the default `by_height` sort (any other sort with a cursor is a server 400 `Bad request`). +- **Transactions**: `ListTransactionsPage` and `TransactionsPager` — cursor envelope over the global listing (newest block first, transactions in block order within each block); `WithOffsetMode("legacy"|"absolute")` selects the offset-based listing instead, and cursor + `offset_mode` is a server 400. `Transaction.BlockID` is documented as the confirming block's hash (empty for mempool transactions). +- **Holders**: `ListCoinHolders`, `ListTokenHolders`, `CoinHoldersPager`, `TokenHoldersPager`, and the `Holder` type (address + amount; token ids are exact case-sensitive bech32, unknown ids are 404 `Token not found`). +- **Order book**: `GetOrderBook`, `OrderBookPager`, and the `OrderBookPage`/`OrderBookLevel`/`OrderBookPrice` types — aggregated price levels (exact reduced-rational price plus floored decimal rendering) per side; required `WithSide`, side-specific cursors, and the truncation invariant (`Truncated` true ⇒ `NextCursor` nil at the server's `OrderBookMaxOrders` = 10000-order cap; the paginator stops there). +- **Shared paginator**: generic `Pager[T]` (`NewPager`, `NextPage`, `Walk`, `NextCursor`, `Truncated`) and the `CursorPage[T]` `{items, next_cursor}` envelope; pre-1.4.1 bare-array responses are accepted and wrapped, a server-issued empty cursor is treated as end-of-listing, and `NextPage` never loses the unserved remainder of a partially consumed page. +- **List options**: `WithItems` (validated 1..=100 = `MaxNumItems`; the server rejects `items=0` on every paginated endpoint), `WithOffset`, `WithCursor` (opaque, non-empty; never constructed client-side), `WithSort`, `WithOffsetMode`, `WithSide`; inapplicable options fail client-side with `*RequestError` — the single-page methods at call time, the cursor pagers at construction time (`PoolsPager` rejects non-default sorts, `TransactionsPager` rejects `WithOffsetMode`). +- **Typed errors**: `HTTPError.Message`/`HTTPError.Kind` populated from the server's `{"error": ...}` body, `ErrorKind` classification, and the `ErrInvalidCursor` / `ErrInvalidNumItems` / `ErrTokenNotFound` sentinels matched via `errors.Is`. + +Documented semantics: page stability is only guaranteed once the indexer's scanner is fully caught up (walks during catch-up/reorgs may skip or repeat entries); offset-based listings remain unchanged as the simple alternative for shallow listings; on pools/holders/order book a cursor silently overrides the offset page position server-side. + +Docs and examples: README indexer section, `docs/indexer.md` (cursor pagination guide, holders/order book reference, error table), and the runnable `examples/indexer-paging`. + --- ## [0.1.0] — 2026-04-16 diff --git a/README.md b/README.md index ad06ba1..e0e77fa 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Requires Go 1.21+. No CGO. The WASM cryptography runtime is embedded in the bina | Guide | Description | |-------|-------------| -| [docs/indexer.md](docs/indexer.md) | Full indexer client reference: chain, blocks, transactions, addresses, pools, tokens, orders, statistics | +| [docs/indexer.md](docs/indexer.md) | Full indexer client reference: chain, blocks, transactions, addresses, pools, tokens, orders, statistics, cursor pagination | | [docs/node.md](docs/node.md) | Full node client reference: chainstate, mempool, P2P, block submission | | [docs/wallet.md](docs/wallet.md) | Full wallet client reference: lifecycle, accounts, balances, transactions | | [docs/wasm.md](docs/wasm.md) | Full WASM client reference: keys, addresses, inputs, outputs, signing, fees | @@ -165,6 +165,14 @@ info, err := c.GetAddressInfo(ctx, "mxtc1...") pools, err := c.ListPools(ctx, indexer.PoolListOpts{Sort: "by_pledge"}) pool, err := c.GetPool(ctx, "pool1...") +// Cursor-paginated listings (pools, transactions, holders, order book) +holderPager := indexer.CoinHoldersPager(c) +err := holderPager.Walk(ctx, func(h indexer.Holder) bool { + fmt.Println(h.Address, h.Amount.Decimal) + return true +}) +book, err := c.GetOrderBook(ctx, "ML_tmltken1...", indexer.WithSide(indexer.SideAsk)) + // Tokens token, err := c.GetToken(ctx, "ttml1...") tokens, err := c.FindTokensByTicker(ctx, "MYTOKEN", indexer.PageOpts{Items: 10}) @@ -177,7 +185,7 @@ order, err := c.GetOrder(ctx, "order1...") stats, err := c.GetCoinStatistics(ctx) ``` -Non-2xx responses are returned as `*indexer.HTTPError` with a `StatusCode` field. +Non-2xx responses are returned as `*indexer.HTTPError` with a `StatusCode` field. The well-known api-server v2 errors are classified in `Kind` and match the `ErrInvalidCursor`, `ErrInvalidNumItems`, and `ErrTokenNotFound` sentinels via `errors.Is`; invalid option values fail client-side with `*indexer.RequestError`. --- @@ -333,6 +341,7 @@ that only import the top-level package do not need to also import `github.com/mi |---|---| | [examples/send-coins/](examples/send-coins/main.go) | Derive key → fetch UTXOs → build, sign, and submit a transaction | | [examples/issue-token/](examples/issue-token/main.go) | Issue a fungible token and mint an initial supply via the wallet daemon | +| [examples/indexer-paging/](examples/indexer-paging/main.go) | Cursor-paginated indexer listings: holders walk, both-side order book, transactions with cursor and offset_mode, pools | --- @@ -369,5 +378,6 @@ Pass the network constant to any function that derives addresses or encodes tran - `node.RPCError` — JSON-RPC error from the node daemon (`Code`, `Message`) - `wallet.RPCError` — JSON-RPC error from the wallet daemon -- `indexer.HTTPError` — non-2xx HTTP response from the indexer (`StatusCode`, `Body`) +- `indexer.HTTPError` — non-2xx HTTP response from the indexer (`StatusCode`, `Body`, `Message`, `Kind`); `errors.Is` matches the `indexer.ErrInvalidCursor` / `indexer.ErrInvalidNumItems` / `indexer.ErrTokenNotFound` sentinels +- `indexer.RequestError` — request rejected client-side before it was sent (invalid `WithItems`/`WithCursor`/`WithSide`/… value or combination), with `Option` and `Reason` - WASM errors are plain `error` values with a descriptive message prefixed by `mintlayer:`. diff --git a/docs/indexer.md b/docs/indexer.md index e66c58a..eec6015 100644 --- a/docs/indexer.md +++ b/docs/indexer.md @@ -26,7 +26,9 @@ type HTTPError struct { ## Pagination -List endpoints accept `PageOpts`: +The indexer exposes two pagination styles. Offset-based listings are the simple alternative for shallow listings; cursor pagination (added in api-server 1.4.1) backs large listings that must be walked in full. + +### Offset pagination (PageOpts) ```go type PageOpts struct { @@ -37,6 +39,50 @@ type PageOpts struct { Pass zero values to use server defaults. +### Cursor pagination (ListOption) + +Pools (`/v2/pool`), the global transaction listing (`/v2/transaction`), the coin/token holders, and the order book answer cursor requests with an envelope: + +```go +type CursorPage[T any] struct { + Items []T `json:"items"` + NextCursor *string `json:"next_cursor"` // nil when the listing is exhausted +} +``` + +One-page methods (`ListPoolsPage`, `ListTransactionsPage`, `ListCoinHolders`, `ListTokenHolders`, `GetOrderBook`) take variadic `ListOption` values and return the envelope (the order book returns its own `OrderBookPage` with the same cursor rules). Pager constructors (`PoolsPager`, `TransactionsPager`, `CoinHoldersPager`, `TokenHoldersPager`, `OrderBookPager`) walk the listing for you, following the server cursor automatically and stopping on a `nil` one: + +```go +pager := indexer.CoinHoldersPager(c) +err := pager.Walk(ctx, func(h indexer.Holder) bool { + fmt.Println(h.Address, h.Amount.Decimal) + return true // return false to stop early +}) +``` + +Options: + +| Option | Endpoints | Meaning | +|---|---|---| +| `WithItems(n)` | all | page size, accepted range 1..100 (`MaxNumItems`). The server rejects `items=0` with 400 `Invalid number of items` on every paginated endpoint, offset-based included; the SDK validates client-side. | +| `WithCursor(cur)` | all cursor endpoints | resume from a server-issued cursor. Cursors are opaque: never construct, decode, or modify one — a fabricated or cross-endpoint cursor is rejected with 400 `Invalid cursor`. | +| `WithOffset(n)` | pools, transactions | offset of an offset-based listing. On the holders/order-book endpoints the cursor defines the page position server-side and the offset is ignored (`items` still applies); the SDK sends both parameters as-is. | +| `WithSort(s)` | pools | `"by_height"` (default) or `"by_pledge"`; any other value fails client-side. Only the default creation-height sort supports cursors: any other sort value combined with a cursor is rejected by the server with 400 `Bad request`. | +| `WithOffsetMode(m)` | transactions | `"legacy"` (default) or `"absolute"`; selects the offset-based listing, which has no cursors. Combining a cursor with `offset_mode` is rejected by the server with 400 `Bad request`. | +| `WithSide(s)` | order book | `"ask"` or `"bid"` — required for the book. Cursors are side-specific (`book-ask` / `book-bid`): an ask cursor on a bid walk returns 400 `Invalid cursor`. | + +Invalid combinations and values fail client-side with `*indexer.RequestError` before any request is sent. Server-side rejections (400/404) are returned as `*indexer.HTTPError` with `Kind` and sentinel matching — see [Errors](#errors). + +Semantics: + +- **Page stability** is only guaranteed once the indexer's scanner is fully caught up. A walk performed during catch-up or a reorg may skip or repeat an entry. +- Cursors may only be taken from `CursorPage.NextCursor` / `Pager.NextCursor`. `Pager.NextCursor()` is nil until the first page is fetched and after the walk ends. If `Walk` stops early mid-page, the current page still holds unserved items: persisting the cursor then and resuming in a fresh pager skips them, so only persist it once the current page has been consumed fully (the same pager serves the remainder first). +- `Walk` and `NextPage` share one consumption position and may be mixed on the same pager: `NextPage` returns the items neither API has consumed yet (remainder of the current page first, then a fresh fetch), and `Walk` resumes wherever the previous calls stopped, never serving an item twice. +- `Pager.NextPage(ctx)` returns `(nil, nil)` once the listing is exhausted; a fetched page is never nil, so `page == nil` uniquely means the walk is finished. Items left unserved by a partial `Walk` are returned by the next `NextPage` before a new page is fetched. Errors are retryable (a failed page does not advance the walk). +- The cursor pagers validate their options at construction time; inapplicable options surface as a deferred `*indexer.RequestError` on the first `NextPage` (named after the pager, e.g. `TransactionsPager` rejects `WithOffsetMode`). + +Pair formatting (`{base}_{quote}`): the native coin ticker matches case-insensitively, while token IDs are exact, case-sensitive bech32. The SDK passes the pair through unchanged — never lowercase it. + --- ## Chain @@ -118,7 +164,39 @@ Returns the transaction IDs included in a block. Use this to page through block func (c *Client) ListTransactions(ctx context.Context, opts PageOpts) ([]Transaction, error) ``` -Returns a paginated list of confirmed transactions across the entire chain. +Returns a paginated list of confirmed transactions across the entire chain, ordered newest block first, transactions in block order within each block. + +### `ListTransactionsPage` + +```go +func (c *Client) ListTransactionsPage(ctx context.Context, opts ...ListOption) (*CursorPage[Transaction], error) +``` + +One page of the global transaction listing as a cursor envelope; pass `NextCursor` to `WithCursor` (or use `TransactionsPager`) to continue the walk. `WithOffsetMode("legacy"|"absolute")` selects the offset-based listing instead, which has no cursors (such a page arrives with a nil `NextCursor`). Combining a cursor with `offset_mode` is rejected by the server with 400 `Bad request`. Per-block transaction listings remain offset-based (see `GetBlockTransactionIDs`). + +### `TransactionsPager` + +```go +func TransactionsPager(c *Client, opts ...ListOption) *Pager[Transaction] +``` + +Walks the global transaction listing (newest block first, transactions in block order within each block). The pager does not accept `WithOffsetMode` (a cursor walk cannot be combined with an offset listing — rejected at construction): use `ListTransactionsPage` with `WithOffsetMode` for the offset-based listing. + +```go +pager := indexer.TransactionsPager(c, indexer.WithItems(100)) +for { + page, err := pager.NextPage(ctx) + if err != nil { + return err + } + if page == nil { + break + } + for _, tx := range page { + fmt.Println(tx.ID, tx.BlockID) + } +} +``` ### `GetTransaction` @@ -126,7 +204,7 @@ Returns a paginated list of confirmed transactions across the entire chain. func (c *Client) GetTransaction(ctx context.Context, id string) (*Transaction, error) ``` -Returns a transaction by ID. The `BlockID`, `Timestamp`, and `Confirmations` fields are empty strings for unconfirmed transactions. +Returns a transaction by ID. `BlockID` is the hash of the confirming block; the `BlockID`, `Timestamp`, and `Confirmations` fields are empty strings for unconfirmed (mempool) transactions. ```go type Transaction struct { @@ -250,6 +328,22 @@ type PoolListOpts struct { } ``` +### `ListPoolsPage` + +```go +func (c *Client) ListPoolsPage(ctx context.Context, opts ...ListOption) (*CursorPage[Pool], error) +``` + +One page of staking pools as a cursor envelope (newest creation height first by default); pass `NextCursor` to `WithCursor` (or use `PoolsPager`) to continue the walk. The default creation-height sort is the only cursor-compatible one: `WithSort("by_pledge")` together with a cursor is rejected by the server with 400 `Bad request`, and without a cursor a non-default sort takes the offset path (no cursors, single page). + +### `PoolsPager` + +```go +func PoolsPager(c *Client, opts ...ListOption) *Pager[Pool] +``` + +Walks the pools listing, following the server cursor automatically. The pager only accepts the default creation-height sort (`WithSort` with any other value is rejected at construction); use `ListPoolsPage` for `"by_pledge"` listings. When `WithOffset` is set without a cursor, the cursor silently overrides the offset page position server-side (`items` still applies). + ### `GetPool` ```go @@ -415,6 +509,46 @@ func (c *Client) ListOrdersByPair(ctx context.Context, askCurrency, giveCurrency Returns orders filtered by a trading pair. Pass `"Coin"` or a token ID (bech32m) for each currency. +### `GetOrderBook` + +```go +func (c *Client) GetOrderBook(ctx context.Context, pair string, opts ...ListOption) (*OrderBookPage, error) +``` + +One aggregated page of the order book for the trading pair `"{base}_{quote}"` (e.g. `"ML_tmltken1..."`). The side is required — pass `WithSide(indexer.SideAsk)` or `WithSide(indexer.SideBid)`. Levels come best-price-first (ascending asks, descending bids). + +```go +type OrderBookPage struct { + Levels []OrderBookLevel `json:"items"` + Truncated bool `json:"truncated,omitempty"` + NextCursor *string `json:"next_cursor"` +} + +type OrderBookLevel struct { + Price OrderBookPrice `json:"price"` + Amount Amount `json:"amount"` +} + +type OrderBookPrice struct { + Atoms string `json:"atoms"` // exact price: reduced rational "numer/denom" (quote atoms per base atom) + Decimal string `json:"decimal"` // floored-toward-zero rendering at the quote currency's decimals +} +``` + +`Amount` on a level is the total remaining base-currency amount across all orders at that price. A nil `NextCursor` means the end of the book — unless `Truncated` is true: the server then hit its per-request aggregation cap (`OrderBookMaxOrders` = 10000 live orders scanned), the levels are an incomplete aggregation, and **no continuation cursor exists** (`NextCursor` is always nil when `Truncated`). The pair is passed through exactly as given: the native coin ticker matches case-insensitively, while token IDs are exact, case-sensitive bech32 — never lowercase user input. + +Cursors are side-specific (`book-ask` / `book-bid`): reusing an ask cursor on a bid walk returns 400 `Invalid cursor`. + +### `OrderBookPager` + +```go +func OrderBookPager(c *Client, pair string, side string, opts ...ListOption) *Pager[OrderBookLevel] +``` + +Walks the order book of `pair` on `side`, following the server cursor automatically and stopping at the end of the book — or as soon as a page reports truncation (`Pager.Truncated()`; a truncated page has no continuation cursor by construction). + +See the [examples/indexer-paging](../examples/indexer-paging/main.go) example for a complete both-side walk. + --- ## Statistics @@ -436,6 +570,42 @@ type CoinStats struct { } ``` +All four counters are always present (api-server 1.4.1+): a counter that has never been written renders as zero, so clients may read the fields directly without nil/emptiness checks. + +### `ListCoinHolders` + +```go +func (c *Client) ListCoinHolders(ctx context.Context, opts ...ListOption) (*CursorPage[Holder], error) +``` + +One page of the native coin holders, largest balance first. The server ignores the offset on this endpoint; the cursor defines the page position. See `CoinHoldersPager` for full walks. + +### `ListTokenHolders` + +```go +func (c *Client) ListTokenHolders(ctx context.Context, tokenID string, opts ...ListOption) (*CursorPage[Holder], error) +``` + +One page of the holders of the given token (bech32, case-sensitive — pass it exactly as issued). Unknown token IDs fail with `*indexer.HTTPError` matching `indexer.ErrTokenNotFound`. + +```go +type Holder struct { + Address string `json:"address"` + Amount Amount `json:"amount"` +} +``` + +`Amount.Decimal` is rendered with the decimals of the listed currency: 9 for the native coin, the token's `number_of_decimals` for tokens. + +### `CoinHoldersPager` / `TokenHoldersPager` + +```go +func CoinHoldersPager(c *Client, opts ...ListOption) *Pager[Holder] +func TokenHoldersPager(c *Client, tokenID string, opts ...ListOption) *Pager[Holder] +``` + +Walk the holders listings, following the server cursor automatically. + ### `GetTokenStatistics` ```go @@ -466,3 +636,47 @@ type Amount struct { ``` All values populated by the server include both fields. When constructing amounts to send to the server (for example in `AddressSend`), you only need to set `Atoms`. + +--- + +## Errors + +Non-2xx responses are returned as `*indexer.HTTPError`: + +```go +type HTTPError struct { + StatusCode int // HTTP status code (400, 404, ...) + Body string // raw response body + Message string // the server's {"error": "..."} field + Kind ErrorKind // classified server error, ErrorKindOther when unrecognised +} +``` + +The api-server's well-known error messages map to `ErrorKind` values, three of which have sentinel errors for `errors.Is`. Matching is gated on the documented status code (400 for the client errors, 404 for the token lookup): the same message arriving under an unexpected status degrades to `ErrorKindOther` and does not match the sentinel. + +| Server response | Kind | Sentinel | +|---|---|---| +| 400 `Invalid cursor` | `ErrorKindInvalidCursor` | `indexer.ErrInvalidCursor` | +| 400 `Invalid number of items` | `ErrorKindInvalidNumItems` | `indexer.ErrInvalidNumItems` | +| 404 `Token not found` | `ErrorKindTokenNotFound` | `indexer.ErrTokenNotFound` | +| 400 `Bad request` | `ErrorKindBadRequest` | — | +| 400 `Invalid offset mode` | `ErrorKindInvalidOffsetMode` | — | +| 400 `Invalid pools sort order` | `ErrorKindInvalidPoolsSortOrder` | — | +| 400 `Invalid token Id` | `ErrorKindInvalidTokenID` | — | +| 400 `Invalid order trading pair` | `ErrorKindInvalidOrderPair` | — | + +```go +holders, err := c.ListTokenHolders(ctx, tokenID) +if errors.Is(err, indexer.ErrTokenNotFound) { + // unknown token id +} +``` + +Invalid options or combinations rejected client-side (before any request) are returned as `*indexer.RequestError`: + +```go +type RequestError struct { + Option string // e.g. "WithItems", "WithCursor", "WithSide" + Reason string // human-readable explanation +} +``` diff --git a/examples/indexer-paging/main.go b/examples/indexer-paging/main.go new file mode 100644 index 0000000..1c15aa0 --- /dev/null +++ b/examples/indexer-paging/main.go @@ -0,0 +1,220 @@ +// Copyright (c) 2026 Mintlayer Institutional FZCO +// Contact: hello@mintlayer.org +// +// Use of this source code is governed by an MIT license +// that can be found in the LICENSE file. + +// Command indexer-paging demonstrates the cursor-paginated api-server v2 +// endpoints (1.4.1+) of the Mintlayer indexer: +// +// 1. A full walk of the native coin holders listing. +// 2. A both-side walk of an order book, including the truncated-page stop. +// 3. The global transaction listing: with a cursor, and with offset_mode. +// 4. A walk of the pools listing (default creation-height sort only — the +// by-pledge sort does not support cursors). +// +// Usage: +// +// go run ./examples/indexer-paging -indexer http://127.0.0.1:3000 \ +// -pair ML_tmltken1vq0f7na4htjv9c5fmaaun8z6t4fgcs2gn3tctxtl3kc9vmz9tvqzhhm6 +// +// The pair flag is optional; pass it as {base}_{quote} with the native coin +// ticker case-insensitive and token IDs exact (case-sensitive bech32). +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "log" + "time" + + "github.com/mintlayer/go-sdk/indexer" +) + +func main() { + indexerURL := flag.String("indexer", "http://127.0.0.1:3000", "indexer base URL") + pair := flag.String("pair", "", "order-book pair as {base}_{quote} (optional)") + items := flag.Uint("items", 10, "page size, 1..100") + flag.Parse() + + if *items == 0 || *items > indexer.MaxNumItems { + log.Fatalf("items must be 1..%d", indexer.MaxNumItems) + } + + c := indexer.New(*indexerURL, indexer.WithTimeout(30*time.Second)) + // The parent cap must cover the sum of the per-walk budgets below (up to + // five walks at 30s each) plus slack, so later demos never inherit an + // already-expired context. + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + + // Each walk derives its own deadline so one slow listing (e.g. every + // transaction on a sizeable chain) cannot starve the demos after it. + walkCtx := func() (context.Context, context.CancelFunc) { + return context.WithTimeout(ctx, 30*time.Second) + } + + wc, cancelWalk := walkCtx() + walkCoinHolders(wc, c, uint32(*items)) + cancelWalk() + + tc, cancelWalk := walkCtx() + walkTransactionsWithCursor(tc, c, uint32(*items)) + cancelWalk() + + oc, cancelWalk := walkCtx() + listTransactionsWithOffsetMode(oc, c) + cancelWalk() + + pc, cancelWalk := walkCtx() + walkPools(pc, c, uint32(*items)) + cancelWalk() + + if *pair != "" { + bc, cancelWalk := walkCtx() + walkOrderBook(bc, c, *pair, uint32(*items)) + cancelWalk() + } +} + +// walkCoinHolders pages through the native coin holders, largest balance +// first, until the server runs out of cursors. +func walkCoinHolders(ctx context.Context, c *indexer.Client, items uint32) { + fmt.Println("== Coin holders (cursor walk) ==") + + pager := indexer.CoinHoldersPager(c, indexer.WithItems(items)) + n := 0 + err := pager.Walk(ctx, func(h indexer.Holder) bool { + if n < 5 { + fmt.Printf(" %s %s ML\n", h.Address, h.Amount.Decimal) + } + n++ + return true + }) + if err != nil { + log.Printf("coin holders walk: %v", err) + return + } + fmt.Printf(" ... %d holders in total\n\n", n) +} + +// walkTransactionsWithCursor walks the global transaction listing (newest +// block first, transactions in block order within each block). BlockID is the +// confirming block's hash; it is empty for pending (mempool) transactions. +func walkTransactionsWithCursor(ctx context.Context, c *indexer.Client, items uint32) { + fmt.Println("== Global transactions (cursor walk) ==") + + pager := indexer.TransactionsPager(c, indexer.WithItems(items)) + var pending int + for { + page, err := pager.NextPage(ctx) + if err != nil { + log.Printf("transactions walk: %v", err) + return + } + if page == nil { + break // nil NextCursor from the server: listing exhausted + } + for _, tx := range page { + if tx.BlockID == "" { + pending++ + } + } + } + fmt.Printf(" done; %d pending transactions seen\n\n", pending) +} + +// listTransactionsWithOffsetMode uses the offset-based listing instead of a +// cursor walk. offset_mode=absolute treats the offset as an absolute +// tx_global_index boundary; the two styles cannot be combined (server 400). +func listTransactionsWithOffsetMode(ctx context.Context, c *indexer.Client) { + fmt.Println("== Global transactions (offset_mode=absolute) ==") + + page, err := c.ListTransactionsPage(ctx, + indexer.WithOffsetMode(indexer.OffsetModeAbsolute), + indexer.WithOffset(0), + indexer.WithItems(5), + ) + if err != nil { + log.Printf("offset listing: %v", err) + return + } + // Offset-mode pages have no cursors: NextCursor is always nil here. + for _, tx := range page.Items { + fmt.Printf(" %s block %s\n", tx.ID, orDash(tx.BlockID)) + } + fmt.Printf(" (%d transactions; offset listings have no NextCursor: %v)\n\n", + len(page.Items), page.NextCursor == nil) +} + +// walkPools walks the pools listing. Only the default creation-height sort +// supports cursors: WithSort(indexer.SortByPledge) plus a cursor is rejected +// by the server with 400 Bad request. +func walkPools(ctx context.Context, c *indexer.Client, items uint32) { + fmt.Println("== Pools (cursor walk, by_height) ==") + + pager := indexer.PoolsPager(c, indexer.WithItems(items)) + n := 0 + err := pager.Walk(ctx, func(p indexer.Pool) bool { + if n < 5 { + fmt.Printf(" %s staked %s\n", p.PoolID, p.StakerBalance.Decimal) + } + n++ + return true + }) + if err != nil { + var httpErr *indexer.HTTPError + if errors.As(err, &httpErr) && httpErr.Kind == indexer.ErrorKindBadRequest { + log.Printf("pools walk rejected by the server (sort+cursor combinations are invalid): %v", err) + return + } + log.Printf("pools walk: %v", err) + return + } + fmt.Printf(" ... %d pools in total\n\n", n) +} + +// walkOrderBook walks both sides of the order book. Cursors are side-specific +// (book-ask vs book-bid), and a page truncated at the server's aggregation cap +// (10000 orders) carries no continuation cursor: the walk stops there even +// though the book is incomplete. +func walkOrderBook(ctx context.Context, c *indexer.Client, pair string, items uint32) { + fmt.Printf("== Order book %s ==\n", pair) + + for _, side := range []string{indexer.SideAsk, indexer.SideBid} { + pager := indexer.OrderBookPager(c, pair, side, indexer.WithItems(items)) + fmt.Printf(" -- %s side --\n", side) + + n := 0 + err := pager.Walk(ctx, func(level indexer.OrderBookLevel) bool { + if n < 5 { + // Price is an exact rational (numer/denom of remaining quote + // atoms per remaining base atom); Decimal is its floored + // rendering at the quote currency's decimals. + fmt.Printf(" price %s (%s) amount %s\n", + level.Price.Atoms, level.Price.Decimal, level.Amount.Decimal) + } + n++ + return true + }) + if err != nil { + log.Printf("%s book walk: %v", side, err) + continue + } + if pager.Truncated() { + fmt.Printf(" ... %d levels shown; BOOK TRUNCATED at the aggregation cap — no continuation cursor exists\n", n) + } else { + fmt.Printf(" ... %d levels shown; end of book\n", n) + } + } + fmt.Println() +} + +func orDash(s string) string { + if s == "" { + return "-" + } + return s +} diff --git a/indexer/client.go b/indexer/client.go index 68be082..1e53890 100644 --- a/indexer/client.go +++ b/indexer/client.go @@ -20,9 +20,18 @@ import ( ) // HTTPError is returned when the server responds with a non-2xx status code. +// Message carries the server's "error" field (the JSON body is preserved in +// Body), and Kind classifies the well-known api-server v2 error responses — +// see ErrorKind and the ErrInvalidCursor / ErrInvalidNumItems / ErrTokenNotFound +// sentinels, which errors.Is matches against. type HTTPError struct { StatusCode int Body string + // Message is the value of the server's {"error": ...} field, or the raw + // body when the response is not that JSON shape. + Message string + // Kind classifies the response; ErrorKindOther when unrecognised. + Kind ErrorKind } func (e *HTTPError) Error() string { @@ -85,9 +94,13 @@ func (c *Client) get(ctx context.Context, path string, query url.Values, out any } defer resp.Body.Close() + // classify non-2xx responses once at the transport boundary so every + // endpoint method (and the paginator) returns typed errors for free. if resp.StatusCode >= 400 { body, _ := io.ReadAll(resp.Body) - return &HTTPError{StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(body))} + trimmed := strings.TrimSpace(string(body)) + message, kind := classifyError(resp.StatusCode, trimmed) + return &HTTPError{StatusCode: resp.StatusCode, Body: trimmed, Message: message, Kind: kind} } if out != nil { @@ -116,7 +129,9 @@ func (c *Client) post(ctx context.Context, path string, body string, out any) er if resp.StatusCode >= 400 { respBody, _ := io.ReadAll(resp.Body) - return &HTTPError{StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(respBody))} + trimmed := strings.TrimSpace(string(respBody)) + message, kind := classifyError(resp.StatusCode, trimmed) + return &HTTPError{StatusCode: resp.StatusCode, Body: trimmed, Message: message, Kind: kind} } if out != nil { diff --git a/indexer/errors.go b/indexer/errors.go new file mode 100644 index 0000000..25a23ac --- /dev/null +++ b/indexer/errors.go @@ -0,0 +1,134 @@ +// Copyright (c) 2026 Mintlayer Institutional FZCO +// Contact: hello@mintlayer.org +// +// Use of this source code is governed by an MIT license +// that can be found in the LICENSE file. + +package indexer + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" +) + +// ErrorKind classifies the well-known api-server v2 error responses. Every +// non-2xx reply carries a JSON body of the form {"error": ""}; the +// message is matched against the documented server error strings. +type ErrorKind int + +const ( + // ErrorKindOther is used for any error response without a recognised message. + ErrorKindOther ErrorKind = iota + // ErrorKindBadRequest is a generic 400 "Bad request" (e.g. a missing or + // invalid query parameter combination). + ErrorKindBadRequest + // ErrorKindInvalidCursor is 400 "Invalid cursor": the cursor was not issued + // by this endpoint (or by this side of the order book), is malformed, or + // points outside the listing. + ErrorKindInvalidCursor + // ErrorKindInvalidNumItems is 400 "Invalid number of items": items was 0 or + // above the per-page cap (MAX_NUM_ITEMS = 100). The server rejects items=0 + // on every paginated v2 endpoint, offset-based listings included. + ErrorKindInvalidNumItems + // ErrorKindInvalidOffsetMode is 400 "Invalid offset mode" (transactions). + ErrorKindInvalidOffsetMode + // ErrorKindInvalidPoolsSortOrder is 400 "Invalid pools sort order". + ErrorKindInvalidPoolsSortOrder + // ErrorKindInvalidTokenID is 400 "Invalid token Id" (malformed token id in + // a token-scoped path, e.g. the token holders or order book endpoints). + ErrorKindInvalidTokenID + // ErrorKindInvalidOrderPair is 400 "Invalid order trading pair". + ErrorKindInvalidOrderPair + // ErrorKindTokenNotFound is 404 "Token not found" (unknown token id). + ErrorKindTokenNotFound +) + +// Sentinel errors for the well-known api-server v2 failures. Use errors.Is to +// match an *HTTPError without string comparison: +// +// if errors.Is(err, indexer.ErrInvalidCursor) { ... } +var ( + // ErrInvalidCursor matches 400 "Invalid cursor" responses. + ErrInvalidCursor = errors.New("invalid cursor") + // ErrInvalidNumItems matches 400 "Invalid number of items" responses. + ErrInvalidNumItems = errors.New("invalid number of items") + // ErrTokenNotFound matches 404 "Token not found" responses. + ErrTokenNotFound = errors.New("token not found") +) + +// RequestError reports a request that client-side validation rejected before it +// was sent. The Option field names the offending list option (e.g. "WithItems"). +type RequestError struct { + Option string + Reason string +} + +func (e *RequestError) Error() string { + return fmt.Sprintf("indexer: invalid %s: %s", e.Option, e.Reason) +} + +// Is maps an *HTTPError onto the sentinel errors declared in this package, so +// that errors.Is works across the typed error surface: +// +// errors.Is(err, indexer.ErrInvalidCursor) +// errors.Is(err, indexer.ErrInvalidNumItems) +// errors.Is(err, indexer.ErrTokenNotFound) +func (e *HTTPError) Is(target error) bool { + switch target { + case ErrInvalidCursor: + return e.Kind == ErrorKindInvalidCursor + case ErrInvalidNumItems: + return e.Kind == ErrorKindInvalidNumItems + case ErrTokenNotFound: + return e.Kind == ErrorKindTokenNotFound + } + return false +} + +// classifyError extracts the server error message and its ErrorKind from a +// non-2xx response body. The api-server renders client errors as +// {"error": ""} with the exact strings below on status 400, and the +// token lookup miss as 404 "Token not found". Message matching is +// case-insensitive and gated on the status code, so a mislabeled or proxied +// 5xx body degrades to ErrorKindOther instead of steering callers into wrong +// handling; the raw message and body are always preserved. +func classifyError(statusCode int, body string) (string, ErrorKind) { + var payload struct { + Error string `json:"error"` + } + message := strings.TrimSpace(body) + if err := json.Unmarshal([]byte(body), &payload); err == nil && payload.Error != "" { + message = payload.Error + } + + switch statusCode { + case http.StatusBadRequest: + // the message is matched case-insensitively: the api-server renders the + // strings above, but wording/casing drift should degrade to + // ErrorKindOther, not crash the caller + switch strings.ToLower(message) { + case "bad request": + return message, ErrorKindBadRequest + case "invalid cursor": + return message, ErrorKindInvalidCursor + case "invalid number of items": + return message, ErrorKindInvalidNumItems + case "invalid offset mode": + return message, ErrorKindInvalidOffsetMode + case "invalid pools sort order": + return message, ErrorKindInvalidPoolsSortOrder + case "invalid token id": + return message, ErrorKindInvalidTokenID + case "invalid order trading pair": + return message, ErrorKindInvalidOrderPair + } + case http.StatusNotFound: + if strings.EqualFold(message, "token not found") { + return message, ErrorKindTokenNotFound + } + } + return message, ErrorKindOther +} diff --git a/indexer/options.go b/indexer/options.go new file mode 100644 index 0000000..c167d38 --- /dev/null +++ b/indexer/options.go @@ -0,0 +1,239 @@ +// Copyright (c) 2026 Mintlayer Institutional FZCO +// Contact: hello@mintlayer.org +// +// Use of this source code is governed by an MIT license +// that can be found in the LICENSE file. + +package indexer + +import ( + "fmt" + "net/url" + "strconv" +) + +// Listing constants shared by the cursor-paginated api-server v2 endpoints. +const ( + // MaxNumItems is the server-side per-page cap (MAX_NUM_ITEMS). A larger + // items value is rejected with 400 "Invalid number of items". + MaxNumItems = 100 + + // SideAsk selects the ask side of an order book: orders asking for the base + // currency (first in the pair) while giving the quote currency. + SideAsk = "ask" + // SideBid selects the bid side of an order book: the reverse of ask. + SideBid = "bid" + + // SortByHeight is the default pool sort: newest creation height first. It + // is the only sort that supports cursor pagination. + SortByHeight = "by_height" + // SortByPledge sorts pools by largest staker balance first. It does not + // support cursor pagination (a cursor combined with it is a server 400). + SortByPledge = "by_pledge" + + // OffsetModeLegacy selects the legacy offset semantics of the global + // transaction listing (the pre-1.4.1 behaviour). It cannot be combined + // with a cursor (server 400). + OffsetModeLegacy = "legacy" + // OffsetModeAbsolute treats the offset as an absolute tx_global_index + // boundary. It cannot be combined with a cursor (server 400). + OffsetModeAbsolute = "absolute" +) + +// listParam is a bitset of the list options a caller set explicitly. It drives +// per-endpoint applicability validation (e.g. WithSide is only meaningful for +// the order book) so that mistakes surface as clear client-side errors instead +// of silently ignored parameters. +type listParam uint8 + +const ( + paramItems listParam = 1 << iota + paramOffset + paramCursor + paramSort + paramOffsetMode + paramSide +) + +// listParams carries the resolved listing parameters of one request. +type listParams struct { + items uint32 + offset uint64 + cursor string + sort string + offsetMode string + side string + + set listParam +} + +// ListOption customises a cursor-paginated listing request (the *Page methods, +// the holders/order book endpoints, and the pagers built from them). Options +// are validated per endpoint: passing an option the endpoint does not support +// (or an out-of-range value) fails the request with a *RequestError before any +// network traffic. +// +// The offset-based listings (ListPools, ListTransactions, ListOrders, …) keep +// taking the plain PageOpts / PoolListOpts structs and are unaffected. +type ListOption func(*listParams) error + +// WithItems sets the page size. The accepted range is 1..=100 (MaxNumItems): +// the server rejects items=0 with 400 "Invalid number of items" on every +// paginated v2 endpoint (offset-based listings included), and caps the page at +// 100. Omit WithItems to use the server default (10). +func WithItems(items uint32) ListOption { + return func(p *listParams) error { + if items == 0 { + return &RequestError{Option: "WithItems", Reason: "items must be at least 1; the server rejects items=0 with 400 invalid number of items"} + } + if items > MaxNumItems { + return &RequestError{Option: "WithItems", Reason: fmt.Sprintf("items must be at most %d (server MAX_NUM_ITEMS)", MaxNumItems)} + } + p.items = items + p.set |= paramItems + return nil + } +} + +// WithOffset sets the offset of an offset-based listing. Only the transaction +// listing and the pools listing still honour an offset when no cursor is used; +// everywhere else the cursor defines the page position server-side and the +// offset is ignored (items still applies). The SDK sends both parameters +// as-is and does not second-guess the server. +func WithOffset(offset uint64) ListOption { + return func(p *listParams) error { + p.offset = offset + p.set |= paramOffset + return nil + } +} + +// WithCursor resumes a listing from a cursor previously returned by the server +// (CursorPage.NextCursor / Pager.NextCursor). Cursors are opaque: never +// construct, decode, or modify one — a fabricated or cross-endpoint cursor is +// rejected by the server with 400 "Invalid cursor". An empty string is +// rejected client-side; omit WithCursor entirely to start from the beginning. +// +// Cursors are endpoint-specific and, for the order book, side-specific: a +// cursor minted by the ask book is rejected on the bid side and vice versa. +func WithCursor(cursor string) ListOption { + return func(p *listParams) error { + if cursor == "" { + return &RequestError{Option: "WithCursor", Reason: "cursor must be a non-empty server-issued value; omit WithCursor to start from the beginning"} + } + p.cursor = cursor + p.set |= paramCursor + return nil + } +} + +// WithSort sets the pool listing sort order: SortByHeight (default) or +// SortByPledge; "" means the server default (SortByHeight). Any other value +// fails client-side. Only the default creation-height sort supports cursor +// pagination; combining any other sort value with a cursor is rejected by the +// server with 400 "Bad request" (the SDK propagates that response). +func WithSort(sort string) ListOption { + return func(p *listParams) error { + switch sort { + case "", SortByHeight, SortByPledge: + default: + return &RequestError{Option: "WithSort", Reason: fmt.Sprintf("unknown sort %q: use %q or %q", sort, SortByHeight, SortByPledge)} + } + p.sort = sort + p.set |= paramSort + return nil + } +} + +// WithOffsetMode selects the offset semantics of the global transaction +// listing: OffsetModeLegacy (default) or OffsetModeAbsolute. The offset modes +// cannot be combined with a cursor — the server rejects the combination with +// 400 "Bad request", which the SDK propagates. +func WithOffsetMode(mode string) ListOption { + return func(p *listParams) error { + switch mode { + case OffsetModeLegacy, OffsetModeAbsolute: + default: + return &RequestError{Option: "WithOffsetMode", Reason: fmt.Sprintf("mode must be %q or %q", OffsetModeLegacy, OffsetModeAbsolute)} + } + p.offsetMode = mode + p.set |= paramOffsetMode + return nil + } +} + +// WithSide selects the side of the order book listing: SideAsk or SideBid. The +// side is required — a book request without it fails client-side. Cursors are +// side-specific (the server tags them book-ask / book-bid), so a cursor minted +// on one side is rejected with 400 "Invalid cursor" on the other. +func WithSide(side string) ListOption { + return func(p *listParams) error { + switch side { + case SideAsk, SideBid: + default: + return &RequestError{Option: "WithSide", Reason: fmt.Sprintf("side must be %q or %q", SideAsk, SideBid)} + } + p.side = side + p.set |= paramSide + return nil + } +} + +// applyListOptions folds opts over a fresh listParams, preserving the first error. +func applyListOptions(opts []ListOption) (*listParams, error) { + p := &listParams{} + for _, opt := range opts { + if opt == nil { + continue + } + if err := opt(p); err != nil { + return nil, err + } + } + return p, nil +} + +// reject rejects the explicitly-set params in disallow, naming the first +// offending option. +func (p *listParams) reject(endpoint string, disallow listParam) error { + if p.set¶mSide&disallow != 0 { + return &RequestError{Option: "WithSide", Reason: fmt.Sprintf("not supported by %s", endpoint)} + } + if p.set¶mOffsetMode&disallow != 0 { + return &RequestError{Option: "WithOffsetMode", Reason: fmt.Sprintf("not supported by %s", endpoint)} + } + if p.set¶mSort&disallow != 0 { + return &RequestError{Option: "WithSort", Reason: fmt.Sprintf("not supported by %s", endpoint)} + } + return nil +} + +// cloneParams returns a copy of p with the cursor swapped to cursor. Used by +// the pagers to advance an already-validated parameter set; the cursor value +// is always a server-issued one delivered through CursorPage.NextCursor. +func cloneParams(p *listParams, cursor string) *listParams { + q := *p + q.cursor = cursor + q.set |= paramCursor + return &q +} + +// listQuery renders the common listing query parameters. An empty cursor is +// only emitted when forceCursor is set (the api-server returns the +// {items, next_cursor} envelope exactly when the cursor parameter is present, +// even when empty; an empty cursor starts the walk from the beginning). +func (p *listParams) listQuery(forceCursor bool) url.Values { + q := url.Values{} + if p.items > 0 { + q.Set("items", strconv.FormatUint(uint64(p.items), 10)) + } + if p.set¶mOffset != 0 { + q.Set("offset", strconv.FormatUint(p.offset, 10)) + } + if p.cursor != "" { + q.Set("cursor", p.cursor) + } else if forceCursor { + q.Set("cursor", "") + } + return q +} diff --git a/indexer/order.go b/indexer/order.go index 087b28b..1645c13 100644 --- a/indexer/order.go +++ b/indexer/order.go @@ -8,9 +8,17 @@ package indexer import ( "context" + "encoding/json" "fmt" + "net/url" + "strings" ) +// OrderBookMaxOrders is the server's per-request cap on live orders scanned +// while aggregating one order-book page. When the cap is hit the response is +// marked Truncated and carries no continuation cursor. +const OrderBookMaxOrders = 10_000 + // ListOrders returns active orders with pagination. func (c *Client) ListOrders(ctx context.Context, opts PageOpts) ([]Order, error) { var result []Order @@ -39,3 +47,130 @@ func (c *Client) ListOrdersByPair(ctx context.Context, askCurrency, giveCurrency } return result, nil } + +// validateOrderPair checks the {base}_{quote} shape client-side without +// altering the input: the base/quote parts stay exactly as passed (the native +// coin ticker matches case-insensitively server-side, while token ids are +// exact case-sensitive bech32). +func validateOrderPair(pair string) error { + parts := strings.Split(pair, "_") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return &RequestError{Option: "pair", Reason: fmt.Sprintf("must be {base}_{quote} with two non-empty parts, got %q", pair)} + } + return nil +} + +// fetchOrderBook fetches one page of the order book for pair at p's cursor +// position. The side is required and cursors are side-specific: a cursor +// minted on the ask side is rejected with 400 "Invalid cursor" on the bid +// side, and vice versa. +func (c *Client) fetchOrderBook(ctx context.Context, pair string, p *listParams) (*OrderBookPage, error) { + if err := p.reject("GetOrderBook", paramSort|paramOffsetMode); err != nil { + return nil, err + } + if p.side == "" { + return nil, &RequestError{Option: "WithSide", Reason: fmt.Sprintf("a side is required: pass WithSide(%q) or WithSide(%q)", SideAsk, SideBid)} + } + // Unlike the list endpoints (pools, transactions, holders), the order book + // handler has no offset path and always answers with the + // {"items": [...], "next_cursor": ...} envelope, so no cursor parameter is + // forced here: the cursor, when present, is sent verbatim, and on the first + // page its absence is equivalent to an empty value (both start the walk + // from the top). forceCursor stays false — it only matters for endpoints + // where an empty cursor selects the envelope over a bare array. + q := p.listQuery(false) + q.Set("side", p.side) + // PathEscape keeps valid {base}_{quote} pairs byte-identical while making + // URL metacharacters in caller input harmless ('#', '?', '%'). + return getRawOrderBook(ctx, c, fmt.Sprintf("/order/pair/%s/book", url.PathEscape(pair)), q) +} + +// getRawOrderBook fetches and decodes one order-book page, normalising the +// server's additive "truncated" field (present only when true). +func getRawOrderBook(ctx context.Context, c *Client, path string, query url.Values) (*OrderBookPage, error) { + raw, err := c.getRaw(ctx, path, query) + if err != nil { + return nil, err + } + var page OrderBookPage + if err := json.Unmarshal(raw, &page); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + if page.Truncated { + // Enforce the documented invariant client-side: a truncated page never + // carries a continuation cursor. + page.NextCursor = nil + } + return &page, nil +} + +// GetOrderBook returns one aggregated page of the order book for the trading +// pair "{base}_{quote}" (e.g. "ML_tmltken1..."). The pair is passed through +// exactly as given: the native coin ticker matches case-insensitively, while +// token ids are exact, case-sensitive bech32. The side is required — pass +// WithSide(SideAsk) or WithSide(SideBid); levels come best-price-first +// (ascending asks, descending bids) with an exact rational price and its +// floored decimal rendering (see OrderBookPrice). +// +// A nil NextCursor means the end of the book — unless Truncated is true, in +// which case the per-request aggregation cap (OrderBookMaxOrders live orders) +// was hit: the levels are an incomplete aggregation and the walk cannot be +// continued (the server sends no cursor for a truncated page; the SDK +// preserves that invariant — NextCursor is always nil when Truncated). Use +// OrderBookPager to walk both sides page by page. +// +// Cursor re-use across sides fails: the server tags cursors per side +// (book-ask / book-bid) and answers a cross-side cursor with 400 +// "Invalid cursor". +// +// When WithOffset is set without a cursor, the cursor silently overrides the +// offset page position server-side (items still applies); the SDK sends both +// parameters and does not alter that behaviour. +func (c *Client) GetOrderBook(ctx context.Context, pair string, opts ...ListOption) (*OrderBookPage, error) { + if err := validateOrderPair(pair); err != nil { + return nil, err + } + p, err := applyListOptions(opts) + if err != nil { + return nil, err + } + return c.fetchOrderBook(ctx, pair, p) +} + +// OrderBookPager returns a pager that walks the order book of pair on the +// given side, following the server cursor automatically and stopping at the +// end of the book — or as soon as a page reports truncation (Pager.Truncated; +// a truncated page has no continuation cursor by construction). The side is +// required and cursors are side-specific; see GetOrderBook. +func OrderBookPager(c *Client, pair string, side string, opts ...ListOption) *Pager[OrderBookLevel] { + if err := validateOrderPair(pair); err != nil { + return fail[OrderBookLevel](err) + } + p, err := applyListOptions(opts) + if err != nil { + return fail[OrderBookLevel](err) + } + if err := p.reject("OrderBookPager", paramSort|paramOffsetMode); err != nil { + return fail[OrderBookLevel](err) + } + // The side comes in positionally; a WithSide option must agree with it. + if p.side != "" && p.side != side { + return fail[OrderBookLevel](&RequestError{Option: "WithSide", Reason: fmt.Sprintf("side %q disagrees with the side argument %q", p.side, side)}) + } + if err := WithSide(side)(p); err != nil { + return fail[OrderBookLevel](err) + } + return NewPager(func(ctx context.Context, cursor *string) ([]OrderBookLevel, *string, bool, error) { + fetch := p + if cursor != nil { + fetch = cloneParams(fetch, *cursor) + } + page, err := c.fetchOrderBook(ctx, pair, fetch) + if err != nil { + return nil, nil, false, err + } + // The server sends next_cursor: null on a truncated page and the SDK + // keeps that invariant; pass it through so the pager stops there too. + return page.Levels, page.NextCursor, page.Truncated, nil + }) +} diff --git a/indexer/paginate.go b/indexer/paginate.go new file mode 100644 index 0000000..b330af3 --- /dev/null +++ b/indexer/paginate.go @@ -0,0 +1,226 @@ +// Copyright (c) 2026 Mintlayer Institutional FZCO +// Contact: hello@mintlayer.org +// +// Use of this source code is governed by an MIT license +// that can be found in the LICENSE file. + +package indexer + +import ( + "context" + "encoding/json" + "fmt" + "net/url" +) + +// CursorPage is the envelope of a cursor-paginated listing: one page of items +// plus the opaque cursor of the next page. NextCursor is nil when the listing +// is exhausted — and, for the order book, also when the response is truncated +// (see OrderBookPage), in which case the walk cannot be continued at all. +type CursorPage[T any] struct { + // Items holds the entries of this page. Empty (but non-nil handling is up + // to the caller) only for an exhausted listing queried from the start. + Items []T `json:"items"` + // NextCursor is the server-issued cursor of the next page, or nil when + // there is no next page (or the walk cannot continue). Pass its value to + // WithCursor to fetch the following page; never construct or mutate one. + NextCursor *string `json:"next_cursor"` +} + +// pageFetcher fetches one page of a listing at the given cursor position +// (nil cursor = from the beginning). truncated reports that the response was a +// truncated order-book page: no continuation cursor exists and the walk must +// stop even though the book is incomplete. +type pageFetcher[T any] func(ctx context.Context, cursor *string) (items []T, next *string, truncated bool, err error) + +// Pager transparently walks a cursor-paginated listing (pools, the global +// transaction listing, the coin/token holders, and the order book). A pager +// follows the server-issued next_cursor automatically, stops on a null cursor, +// and surfaces typed errors (*HTTPError with Kind, *RequestError for invalid +// options). +// +// A pager is single-use and not safe for concurrent use: pages are fetched +// lazily by NextPage, or item by item via Walk. +// +// Page stability is only guaranteed once the indexer's scanner is fully caught +// up; a walk performed during catch-up or a reorg may skip or repeat an entry. +type Pager[T any] struct { + fetch pageFetcher[T] + + cursor *string // position of the next page to fetch + items []T // current page + pos int // items of the current page already consumed (served via Walk or handed out via NextPage) + done bool // the listing is exhausted (or truncated) + truncated bool // last fetched page reported truncation (order book) + failed error // deferred construction/validation error + started bool // at least one page was fetched +} + +// NewPager creates a pager over a page-fetching function. It is the escape +// hatch for walking listings that the package does not wrap directly; prefer +// the typed constructors (PoolsPager, TransactionsPager, CoinHoldersPager, +// TokenHoldersPager, OrderBookPager) whenever one exists. +// +// The fetch function must return the page items, the next cursor (nil when the +// listing is exhausted), and whether the page was truncated (order book only; +// false elsewhere). A truncated page must come with a nil next cursor. A nil +// fetch function yields a pager that surfaces a deferred error on first use +// instead of panicking. +func NewPager[T any](fetch pageFetcher[T]) *Pager[T] { + if fetch == nil { + return fail[T](&RequestError{Option: "NewPager", Reason: "fetch function must not be nil"}) + } + return &Pager[T]{fetch: fetch} +} + +// fail returns a pager whose first use reports err (used for construction-time +// option validation errors). +func fail[T any](err error) *Pager[T] { + return &Pager[T]{failed: err, done: true} +} + +// NextPage fetches and returns the next batch of items neither consumed yet: +// the unserved remainder of the current page first (whatever Walk or a +// previous NextPage call has not reached), then a fresh page fetch. Once the +// listing is exhausted (or an order book walk hit a truncated page), it +// returns (nil, nil). Options and transport errors are returned as-is and are +// retryable: a failed NextPage does not advance the walk. A fetched page is +// normalized to a non-nil slice, so page == nil uniquely means the walk is +// finished. Walk and NextPage share one consumption position, so they can be +// mixed on the same pager without duplicating or dropping items. +func (p *Pager[T]) NextPage(ctx context.Context) ([]T, error) { + if p.failed != nil { + return nil, p.failed + } + if p.fetch == nil { + // The zero value bypasses NewPager's nil check; surface the same + // deferred-error contract instead of panicking. + return nil, &RequestError{Option: "NewPager", Reason: "use NewPager to construct a Pager; the zero value has no fetch function"} + } + if p.pos < len(p.items) { + // Hand out whatever neither Walk nor a previous NextPage call consumed + // of the current page before fetching anew. + items := p.items[p.pos:] + p.pos = len(p.items) + return items, nil + } + if p.done { + return nil, nil + } + items, next, truncated, err := p.fetch(ctx, p.cursor) + if err != nil { + return nil, err + } + p.started = true + p.truncated = truncated + if items == nil { + items = []T{} + } + p.items = items + p.pos = len(items) // the page is handed out whole via the return value + if next == nil || truncated || *next == "" { + // A server-issued empty cursor is not a continuation position (it would + // re-serve the first page forever); treat it as end-of-listing, matching + // the client-side WithCursor validation. + p.done = true + p.cursor = nil + } else { + p.cursor = next + } + return items, nil +} + +// Walk calls fn for every remaining item of the listing, across pages, in +// listing order. It resumes from wherever previous Walk or NextPage calls +// stopped, and never serves an item twice. It stops early — without error — +// when fn returns false; the next Walk call or NextPage call continues after +// the last served item. Transport and validation errors abort the walk and +// are returned. +func (p *Pager[T]) Walk(ctx context.Context, fn func(item T) bool) error { + for { + if p.pos >= len(p.items) { + page, err := p.NextPage(ctx) + if err != nil { + return err + } + if page == nil { + return nil + } + // Adopt the freshly fetched page: NextPage marked it fully handed + // out via its return value, but Walk consumes pages internally. + p.items = page + p.pos = 0 + } + for ; p.pos < len(p.items); p.pos++ { + if !fn(p.items[p.pos]) { + p.pos++ // fn consumed this item; resume after it + return nil + } + } + } +} + +// NextCursor returns the server-issued cursor that resumes the listing after +// the last page fetched to its end, or nil once the listing is exhausted, the +// walk was truncated (order book), or nothing has been fetched yet. After Walk +// stops early mid-page, the current page still holds unserved items: resuming +// from this cursor in a fresh pager skips them, so only persist it once the +// current page has been consumed fully — or keep using the same pager, which +// serves the remainder first. The returned string is the same value the server +// delivered; it is never constructed or modified client-side. +func (p *Pager[T]) NextCursor() *string { + if !p.started { + return nil + } + return p.cursor +} + +// Truncated reports whether the last fetched page was a truncated order-book +// page: the per-request aggregation cap (OrderBookMaxOrders live orders) was +// hit, the levels in hand are an incomplete aggregation, and no continuation +// cursor exists. Always false for the non-order-book listings. +func (p *Pager[T]) Truncated() bool { + return p.truncated +} + +// getPage fetches one listing page and decodes the {items, next_cursor} +// envelope. Older api-servers answer cursor-less requests with a bare JSON +// array (the pre-1.4.1 shape); that shape is accepted and wrapped into an +// envelope with a nil next cursor. +func getPage[T any](ctx context.Context, c *Client, path string, query url.Values) (*CursorPage[T], error) { + raw, err := c.getRaw(ctx, path, query) + if err != nil { + return nil, err + } + if len(raw) > 0 && raw[0] == '[' { + var items []T + if err := json.Unmarshal(raw, &items); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + return &CursorPage[T]{Items: items}, nil + } + var page CursorPage[T] + if err := json.Unmarshal(raw, &page); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + return &page, nil +} + +// pageResult adapts a getPage result to the pageFetcher signature. +func pageResult[T any](page *CursorPage[T], err error) ([]T, *string, bool, error) { + if err != nil { + return nil, nil, false, err + } + return page.Items, page.NextCursor, false, nil +} + +// getRaw performs a GET request and returns the raw response body, reusing +// get's transport and error classification (decoding into *json.RawMessage +// preserves the raw bytes). +func (c *Client) getRaw(ctx context.Context, path string, query url.Values) (json.RawMessage, error) { + var raw json.RawMessage + if err := c.get(ctx, path, query, &raw); err != nil { + return nil, err + } + return raw, nil +} diff --git a/indexer/pagination_test.go b/indexer/pagination_test.go new file mode 100644 index 0000000..1eeebdf --- /dev/null +++ b/indexer/pagination_test.go @@ -0,0 +1,947 @@ +// Copyright (c) 2026 Mintlayer Institutional FZCO +// Contact: hello@mintlayer.org +// +// Use of this source code is governed by an MIT license +// that can be found in the LICENSE file. + +package indexer_test + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/mintlayer/go-sdk/indexer" +) + +// readFixture loads a canned JSON response from indexer/testdata. +func readFixture(t *testing.T, name string) json.RawMessage { + t.Helper() + raw, err := os.ReadFile(filepath.Join("testdata", name)) + if err != nil { + t.Fatalf("read fixture %s: %v", name, err) + } + return json.RawMessage(raw) +} + +// recordedRequest is one request captured by a routed test server. +type recordedRequest struct { + path string + params url.Values +} + +// route describes one fixture served for a given incoming cursor value. +type route struct { + cursor string // value of the "cursor" query param ("" = first request) + file string // fixture served from testdata + status int // when non-zero, serve this status with a JSON error body instead + errMsg string // error message for status != 0 +} + +// routedServer serves fixtures per the given routes (matched on the "cursor" +// query param) and records every request. Requests are matched in order for +// status-only routes. +func routedServer(t *testing.T, routes []route) (*httptest.Server, *[]recordedRequest) { + t.Helper() + byCursor := map[string]route{} + for _, r := range routes { + byCursor[r.cursor] = r + } + requests := &[]recordedRequest{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + *requests = append(*requests, recordedRequest{path: r.URL.Path, params: r.URL.Query()}) + cursor := r.URL.Query().Get("cursor") + rt, ok := byCursor[cursor] + if !ok { + t.Errorf("unexpected cursor %q for %s", cursor, r.URL.Path) + http.Error(w, `{"error":"Bad request"}`, http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + if rt.status != 0 { + w.WriteHeader(rt.status) + fmt.Fprintf(w, `{"error":%q}`, rt.errMsg) + return + } + w.Write(readFixture(t, rt.file)) + })) + t.Cleanup(srv.Close) + return srv, requests +} + +// firstPageCursorEmpty asserts the cursor parameter is present but empty on a +// first-page request: the api-server switches to the cursor envelope based on +// parameter presence, even for an empty cursor. +func firstPageCursorEmpty(t *testing.T, reqs *[]recordedRequest) { + t.Helper() + if len(*reqs) == 0 { + t.Fatal("no requests recorded") + } + q, ok := (*reqs)[0].params["cursor"] + if !ok { + t.Fatalf("first request missing cursor param: %v", (*reqs)[0].params) + } + if len(q) != 1 || q[0] != "" { + t.Fatalf("first request cursor = %v, want present-but-empty", q) + } +} + +// --- Pools --- + +func TestPoolsPagerFullWalk(t *testing.T) { + srv, reqs := routedServer(t, []route{ + {cursor: "", file: "pools_page1.json"}, + {cursor: "pools-cursor-2", file: "pools_page2.json"}, + }) + c := indexer.New(srv.URL) + + pager := indexer.PoolsPager(c, indexer.WithItems(50)) + var ids []string + err := pager.Walk(context.Background(), func(p indexer.Pool) bool { + ids = append(ids, p.PoolID) + return true + }) + if err != nil { + t.Fatalf("Walk: %v", err) + } + if len(ids) != 3 { + t.Fatalf("walked %d pools, want 3 (%v)", len(ids), ids) + } + if pager.NextCursor() != nil || pager.Truncated() { + t.Fatalf("exhausted pager reports cursor=%v truncated=%v", pager.NextCursor(), pager.Truncated()) + } + firstPageCursorEmpty(t, reqs) + second := (*reqs)[1].params + if second.Get("cursor") != "pools-cursor-2" || second.Get("items") != "50" { + t.Fatalf("second request params = %v", second) + } + // A retry after exhaustion is a no-op. + page, err := pager.NextPage(context.Background()) + if page != nil || err != nil { + t.Fatalf("NextPage after walk = (%v, %v), want (nil, nil)", page, err) + } +} + +func TestPoolsListPageLastPage(t *testing.T) { + srv, _ := routedServer(t, []route{ + {cursor: "pools-cursor-2", file: "pools_page2.json"}, + }) + c := indexer.New(srv.URL) + + page, err := c.ListPoolsPage(context.Background(), indexer.WithCursor("pools-cursor-2")) + if err != nil { + t.Fatalf("ListPoolsPage: %v", err) + } + if page.NextCursor != nil { + t.Fatalf("last page NextCursor = %v, want nil", *page.NextCursor) + } + if len(page.Items) != 1 { + t.Fatalf("last page items = %d, want 1", len(page.Items)) + } +} + +func TestPoolsSortWithCursorServerReject(t *testing.T) { + // Cursor + non-default sort is rejected by the server; the SDK propagates the 400. + srv, reqs := routedServer(t, []route{ + {cursor: "some-cursor", status: http.StatusBadRequest, errMsg: "Bad request"}, + }) + c := indexer.New(srv.URL) + + _, err := c.ListPoolsPage(context.Background(), + indexer.WithSort(indexer.SortByPledge), indexer.WithCursor("some-cursor")) + if err == nil { + t.Fatal("ListPoolsPage with sort+cursor, want 400") + } + var httpErr *indexer.HTTPError + if !errors.As(err, &httpErr) || httpErr.StatusCode != http.StatusBadRequest { + t.Fatalf("err = %v, want *HTTPError 400", err) + } + if got := (*reqs)[0].params.Get("sort"); got != indexer.SortByPledge { + t.Fatalf("sort param = %q", got) + } +} + +func TestPoolsNonDefaultSortTakesOffsetPath(t *testing.T) { + // Without an explicit cursor, a non-default sort must take the offset-based + // path: no cursor parameter at all, and a bare-array response wrapped with a + // nil NextCursor. + var requests []recordedRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests = append(requests, recordedRequest{path: r.URL.Path, params: r.URL.Query()}) + if _, ok := r.URL.Query()["cursor"]; ok { + t.Errorf("by-pledge listing sent cursor param %q", r.URL.Query().Get("cursor")) + } + if got := r.URL.Query().Get("sort"); got != indexer.SortByPledge { + t.Errorf("sort param = %q, want by_pledge", got) + } + w.Header().Set("Content-Type", "application/json") + w.Write(readFixture(t, "pools_offset.json")) + })) + defer srv.Close() + c := indexer.New(srv.URL) + + page, err := c.ListPoolsPage(context.Background(), indexer.WithSort(indexer.SortByPledge)) + if err != nil { + t.Fatalf("ListPoolsPage: %v", err) + } + if len(page.Items) != 1 || page.NextCursor != nil { + t.Fatalf("offset page = (%d items, cursor %v)", len(page.Items), page.NextCursor) + } +} + +// --- Holders --- + +func TestCoinHoldersPagerFullWalk(t *testing.T) { + srv, reqs := routedServer(t, []route{ + {cursor: "", file: "holders_page1.json"}, + {cursor: "holders-cursor-2", file: "holders_page2.json"}, + }) + c := indexer.New(srv.URL) + + pager := indexer.CoinHoldersPager(c, indexer.WithItems(2)) + var got []indexer.Holder + err := pager.Walk(context.Background(), func(h indexer.Holder) bool { + got = append(got, h) + return true + }) + if err != nil { + t.Fatalf("Walk: %v", err) + } + if len(got) != 3 { + t.Fatalf("walked %d holders, want 3", len(got)) + } + if got[0].Amount.Atoms != "1500000000000" || got[0].Amount.Decimal != "1500.000000000" { + t.Fatalf("holder amount = %+v", got[0].Amount) + } + firstPageCursorEmpty(t, reqs) + // The cursor silently overrides the offset server-side; the SDK sends both + // parameters as-is. + second := (*reqs)[1].params + if second.Get("cursor") != "holders-cursor-2" { + t.Fatalf("second request cursor = %q", second.Get("cursor")) + } +} + +func TestTokenHoldersWalkAndNotFound(t *testing.T) { + const tokenID = "tglp0vg6mdcs9o6npgcf5785xzsdq2c4rtq5wl6asanjk5mvgc2vmdy5y3vmm4z" + + srv, reqs := routedServer(t, []route{ + {cursor: "", file: "holders_page1.json"}, + {cursor: "holders-cursor-2", file: "holders_page2.json"}, + }) + c := indexer.New(srv.URL) + + // The token id must reach the server byte-for-byte (bech32 is case-sensitive). + pager := indexer.TokenHoldersPager(c, tokenID) + n := 0 + err := pager.Walk(context.Background(), func(indexer.Holder) bool { n++; return true }) + if err != nil || n != 3 { + t.Fatalf("Walk = (%d holders, %v), want (3, nil)", n, err) + } + if got := (*reqs)[0].path; got != "/api/v2/statistics/token/"+tokenID+"/holders" { + t.Fatalf("path = %q", got) + } + + // Unknown token: 404 "Token not found" maps to ErrTokenNotFound. + errSrv := errorHandler(t, http.StatusNotFound, `{"error":"Token not found"}`) + defer errSrv.Close() + _, err = indexer.New(errSrv.URL).ListTokenHolders(context.Background(), tokenID) + if !errors.Is(err, indexer.ErrTokenNotFound) { + t.Fatalf("err = %v, want errors.Is(ErrTokenNotFound)", err) + } + var httpErr *indexer.HTTPError + if !errors.As(err, &httpErr) || httpErr.Kind != indexer.ErrorKindTokenNotFound || httpErr.Message != "Token not found" { + t.Fatalf("typed error = %#v", err) + } +} + +// --- Global transactions --- + +func TestTransactionsPagerFullWalk(t *testing.T) { + srv, reqs := routedServer(t, []route{ + {cursor: "", file: "transactions_page1.json"}, + {cursor: "tx-cursor-2", file: "transactions_page2.json"}, + }) + c := indexer.New(srv.URL) + + pager := indexer.TransactionsPager(c) + var txs []indexer.Transaction + err := pager.Walk(context.Background(), func(tx indexer.Transaction) bool { + txs = append(txs, tx) + return true + }) + if err != nil { + t.Fatalf("Walk: %v", err) + } + if len(txs) != 3 { + t.Fatalf("walked %d transactions, want 3", len(txs)) + } + // block_id carries the confirming block hash; null renders as "" (pending). + if txs[0].BlockID != "aa11456bd8f83b02f0f5e4b1c1a2d3e4f5061728394a5b6c7d8e9f0a1b2c3d4e" { + t.Fatalf("confirmed tx block_id = %q", txs[0].BlockID) + } + if txs[1].BlockID != "" { + t.Fatalf("pending tx block_id = %q, want empty", txs[1].BlockID) + } + firstPageCursorEmpty(t, reqs) +} + +func TestTransactionsOffsetModeSinglePage(t *testing.T) { + // offset_mode selects the offset-based listing (bare array, no cursors). + srv := jsonHandler(t, json.RawMessage(readFixture(t, "transactions_offset.json"))) + defer srv.Close() + c := indexer.New(srv.URL) + + page, err := c.ListTransactionsPage(context.Background(), + indexer.WithOffsetMode(indexer.OffsetModeAbsolute), indexer.WithOffset(10), indexer.WithItems(5)) + if err != nil { + t.Fatalf("ListTransactionsPage: %v", err) + } + if len(page.Items) != 1 || page.NextCursor != nil { + t.Fatalf("offset page = (%d items, cursor %v)", len(page.Items), page.NextCursor) + } +} + +func TestTransactionsCursorWithOffsetModeServerReject(t *testing.T) { + // cursor + offset_mode is a server 400 "Bad request", propagated as-is. + srv := errorHandler(t, http.StatusBadRequest, `{"error":"Bad request"}`) + defer srv.Close() + c := indexer.New(srv.URL) + + _, err := c.ListTransactionsPage(context.Background(), + indexer.WithCursor("tx-cursor-2"), indexer.WithOffsetMode(indexer.OffsetModeLegacy)) + var httpErr *indexer.HTTPError + if !errors.As(err, &httpErr) || httpErr.StatusCode != http.StatusBadRequest || httpErr.Message != "Bad request" { + t.Fatalf("err = %v, want 400 Bad request", err) + } +} + +// --- Order book --- + +func TestOrderBookBothSidesWalk(t *testing.T) { + srv, reqs := routedServer(t, []route{ + {cursor: "", file: "book_asks_page1.json"}, + {cursor: "book-ask-cursor-2", file: "book_asks_page2.json"}, + }) + c := indexer.New(srv.URL) + const pair = "ML_tmlp0vg6mdcs9o6npgcf5785xzsdq2c4rtq5wl6asanjk5mvgc2vmdy5y3vmm4z" + + // Ask side: exact rational price plus floored decimal rendering. + page, err := c.GetOrderBook(context.Background(), pair, indexer.WithSide(indexer.SideAsk), indexer.WithItems(10)) + if err != nil { + t.Fatalf("GetOrderBook: %v", err) + } + if page.Truncated { + t.Fatal("first ask page must not be truncated") + } + if page.NextCursor == nil || *page.NextCursor != "book-ask-cursor-2" { + t.Fatalf("ask NextCursor = %v", page.NextCursor) + } + if got := page.Levels[0].Price; got.Atoms != "1/3" || got.Decimal != "0.333333333" { + t.Fatalf("ask price = %+v, want 1/3 floored to 0.333333333", got) + } + + // Continue with the pager until the end of the book. + pager := indexer.OrderBookPager(c, pair, indexer.SideAsk, + indexer.WithCursor(*page.NextCursor), indexer.WithItems(10)) + var levels []indexer.OrderBookLevel + if err := pager.Walk(context.Background(), func(l indexer.OrderBookLevel) bool { + levels = append(levels, l) + return true + }); err != nil { + t.Fatalf("ask Walk: %v", err) + } + if len(levels) != 1 || pager.Truncated() { + t.Fatalf("ask continuation = %d levels, truncated %v", len(levels), pager.Truncated()) + } + for i, r := range *reqs { + if r.params.Get("side") != indexer.SideAsk { + t.Fatalf("request %d side = %q", i, r.params.Get("side")) + } + } + // The pair must reach the server unchanged (case preserved). + if got := (*reqs)[0].path; !strings.Contains(got, "/order/pair/"+pair+"/book") { + t.Fatalf("path = %q", got) + } + // The order book always answers with the envelope, so the first request + // carries no cursor parameter at all (absence is not the pools/holders + // style present-but-empty signal). + if _, ok := (*reqs)[0].params["cursor"]; ok { + t.Fatalf("first book request sent a cursor param: %v", (*reqs)[0].params) + } + + // Bid side: descending prices, ends after one page. + bidSrv, bidReqs := routedServer(t, []route{{cursor: "", file: "book_bids_page1.json"}}) + bidPager := indexer.OrderBookPager(indexer.New(bidSrv.URL), "ML_"+strings.ToUpper("tmlp0vg6mdcs9o6npgcf5785xzsdq2c4rtq5wl6asanjk5mvgc2vmdy5y3vmm4z"), indexer.SideBid) + n := 0 + if err := bidPager.Walk(context.Background(), func(indexer.OrderBookLevel) bool { n++; return true }); err != nil { + t.Fatalf("bid Walk: %v", err) + } + if n != 2 || bidPager.NextCursor() != nil { + t.Fatalf("bid walk = %d levels, cursor %v", n, bidPager.NextCursor()) + } + if got := (*bidReqs)[0].params.Get("side"); got != indexer.SideBid { + t.Fatalf("bid side param = %q", got) + } +} + +func TestOrderBookTruncatedStopsWalk(t *testing.T) { + // The server hit the aggregation cap: truncated=true and next_cursor=null. + // The walk must stop even though the book is incomplete — and no + // continuation may be attempted. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cursor := r.URL.Query().Get("cursor") + if cursor != "" { + t.Errorf("walk continued after truncation with cursor %q", cursor) + } + w.Header().Set("Content-Type", "application/json") + w.Write(readFixture(t, "book_asks_truncated.json")) + })) + defer srv.Close() + c := indexer.New(srv.URL) + + pager := indexer.OrderBookPager(c, "ML_tmlp0vg6mdcs9o6npgcf5785xzsdq2c4rtq5wl6asanjk5mvgc2vmdy5y3vmm4z", indexer.SideAsk) + var n int + if err := pager.Walk(context.Background(), func(indexer.OrderBookLevel) bool { n++; return true }); err != nil { + t.Fatalf("Walk: %v", err) + } + if n != 1 { + t.Fatalf("truncated walk served %d levels, want 1", n) + } + if !pager.Truncated() { + t.Fatal("pager.Truncated() = false after truncated page") + } + if pager.NextCursor() != nil { + t.Fatal("truncated walk exposes a continuation cursor; must be nil") + } + + // The raw page mirrors the invariant: Truncated true, NextCursor nil. + page, err := c.GetOrderBook(context.Background(), "ML_tmlp0vg6mdcs9o6npgcf5785xzsdq2c4rtq5wl6asanjk5mvgc2vmdy5y3vmm4z", indexer.WithSide(indexer.SideAsk)) + if err != nil { + t.Fatalf("GetOrderBook: %v", err) + } + if !page.Truncated || page.NextCursor != nil { + t.Fatalf("truncated page = (truncated %v, cursor %v)", page.Truncated, page.NextCursor) + } +} + +func TestOrderBookCrossSideCursorRejected(t *testing.T) { + // An ask-side cursor on a bid walk: 400 "Invalid cursor", mapped to + // ErrInvalidCursor. + srv := errorHandler(t, http.StatusBadRequest, `{"error":"Invalid cursor"}`) + defer srv.Close() + c := indexer.New(srv.URL) + + _, err := c.GetOrderBook(context.Background(), "ML_tmlp0vg6mdcs9o6npgcf5785xzsdq2c4rtq5wl6asanjk5mvgc2vmdy5y3vmm4z", + indexer.WithSide(indexer.SideBid), indexer.WithCursor("book-ask-cursor-2")) + if !errors.Is(err, indexer.ErrInvalidCursor) { + t.Fatalf("err = %v, want errors.Is(ErrInvalidCursor)", err) + } +} + +func TestOrderBookSideRequired(t *testing.T) { + srv := jsonHandler(t, map[string]any{}) + defer srv.Close() + c := indexer.New(srv.URL) + + if _, err := c.GetOrderBook(context.Background(), "ML_tml"); err == nil { + t.Fatal("GetOrderBook without side, want client-side error") + } else { + var reqErr *indexer.RequestError + if !errors.As(err, &reqErr) || reqErr.Option != "WithSide" { + t.Fatalf("err = %v, want RequestError{WithSide}", err) + } + } + pager := indexer.OrderBookPager(c, "ML_tml", "") + if _, err := pager.NextPage(context.Background()); err == nil { + t.Fatal("OrderBookPager without side, want deferred error") + } +} + +func TestOrderBookPairValidation(t *testing.T) { + srv := jsonHandler(t, map[string]any{}) + defer srv.Close() + c := indexer.New(srv.URL) + + for _, pair := range []string{"", "ML", "a_b_c", "_tml", "ML_"} { + if _, err := c.GetOrderBook(context.Background(), pair, indexer.WithSide(indexer.SideAsk)); err == nil { + t.Fatalf("GetOrderBook(%q) accepted, want RequestError", pair) + } + } +} + +// --- Client-side option validation --- + +func TestListOptionValidation(t *testing.T) { + srv := jsonHandler(t, map[string]any{}) + defer srv.Close() + c := indexer.New(srv.URL) + ctx := context.Background() + + cases := []struct { + name string + exercise func() error + }{ + {"items=0", func() error { _, err := c.ListCoinHolders(ctx, indexer.WithItems(0)); return err }}, + {"items>100", func() error { _, err := c.ListPoolsPage(ctx, indexer.WithItems(101)); return err }}, + {"empty cursor", func() error { _, err := c.ListTransactionsPage(ctx, indexer.WithCursor("")); return err }}, + {"bad offset mode", func() error { _, err := c.ListTransactionsPage(ctx, indexer.WithOffsetMode("bogus")); return err }}, + {"bad side", func() error { _, err := c.GetOrderBook(ctx, "ML_tml", indexer.WithSide("bogus")); return err }}, + {"bad sort", func() error { _, err := c.ListPoolsPage(ctx, indexer.WithSort("byheight")); return err }}, + {"sort on book", func() error { + _, err := c.GetOrderBook(ctx, "ML_tml", indexer.WithSide(indexer.SideAsk), indexer.WithSort(indexer.SortByHeight)) + return err + }}, + {"offset mode on pools", func() error { + _, err := c.ListPoolsPage(ctx, indexer.WithOffsetMode(indexer.OffsetModeAbsolute)) + return err + }}, + {"side on transactions", func() error { _, err := c.ListTransactionsPage(ctx, indexer.WithSide(indexer.SideAsk)); return err }}, + {"offset mode on holders", func() error { + _, err := c.ListCoinHolders(ctx, indexer.WithOffsetMode(indexer.OffsetModeLegacy)) + return err + }}, + {"sort on holders", func() error { + _, err := c.ListTokenHolders(ctx, "tok", indexer.WithSort(indexer.SortByPledge)) + return err + }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := tc.exercise() + var reqErr *indexer.RequestError + if !errors.As(err, &reqErr) { + t.Fatalf("err = %v, want *indexer.RequestError", err) + } + if reqErr.Option == "" || reqErr.Reason == "" { + t.Fatalf("RequestError missing option/reason: %+v", reqErr) + } + }) + } + + // Valid boundary values are accepted client-side. + if _, err := c.ListPoolsPage(ctx, indexer.WithItems(100)); err != nil { + t.Fatalf("WithItems(100): %v", err) + } + if _, err := c.ListPoolsPage(ctx, indexer.WithItems(1)); err != nil { + t.Fatalf("WithItems(1): %v", err) + } +} + +// --- Typed error shapes --- + +func TestTypedErrorShapes(t *testing.T) { + cases := []struct { + name string + status int + body string + wantKind indexer.ErrorKind + wantSentinel error + }{ + {"invalid cursor", 400, `{"error":"Invalid cursor"}`, indexer.ErrorKindInvalidCursor, indexer.ErrInvalidCursor}, + {"invalid num items", 400, `{"error":"Invalid number of items"}`, indexer.ErrorKindInvalidNumItems, indexer.ErrInvalidNumItems}, + {"bad request", 400, `{"error":"Bad request"}`, indexer.ErrorKindBadRequest, nil}, + {"invalid offset mode", 400, `{"error":"Invalid offset mode"}`, indexer.ErrorKindInvalidOffsetMode, nil}, + {"invalid pools sort", 400, `{"error":"Invalid pools sort order"}`, indexer.ErrorKindInvalidPoolsSortOrder, nil}, + {"invalid token id", 400, `{"error":"Invalid token Id"}`, indexer.ErrorKindInvalidTokenID, nil}, + {"invalid order pair", 400, `{"error":"Invalid order trading pair"}`, indexer.ErrorKindInvalidOrderPair, nil}, + {"token not found", 404, `{"error":"Token not found"}`, indexer.ErrorKindTokenNotFound, indexer.ErrTokenNotFound}, + {"unrecognised body", 500, `{"error":"boom"}`, indexer.ErrorKindOther, nil}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + srv := errorHandler(t, tc.status, tc.body) + defer srv.Close() + _, err := indexer.New(srv.URL).GetCoinStatistics(context.Background()) + + var httpErr *indexer.HTTPError + if !errors.As(err, &httpErr) { + t.Fatalf("err = %v, want *indexer.HTTPError", err) + } + if httpErr.Kind != tc.wantKind { + t.Fatalf("Kind = %v, want %v", httpErr.Kind, tc.wantKind) + } + if tc.wantSentinel != nil && !errors.Is(err, tc.wantSentinel) { + t.Fatalf("errors.Is(%v, %v) = false", err, tc.wantSentinel) + } + if !strings.Contains(httpErr.Error(), fmt.Sprintf("%d", tc.status)) { + t.Fatalf("Error() = %q, want status mention", httpErr.Error()) + } + }) + } +} + +func TestCoinStatsZeroCounters(t *testing.T) { + // Unwritten counters render as "0"; the SDK keeps the server rendering. + srv := jsonHandler(t, map[string]any{ + "circulating_supply": map[string]string{"atoms": "0", "decimal": "0.000000000"}, + "preminted": map[string]string{"atoms": "0", "decimal": "0.000000000"}, + "burned": map[string]string{"atoms": "0", "decimal": "0.000000000"}, + "staked": map[string]string{"atoms": "0", "decimal": "0.000000000"}, + }) + defer srv.Close() + + stats, err := indexer.New(srv.URL).GetCoinStatistics(context.Background()) + if err != nil { + t.Fatalf("GetCoinStatistics: %v", err) + } + for _, f := range []struct { + name string + amount indexer.Amount + }{ + {"circulating_supply", stats.CirculatingSupply}, + {"preminted", stats.Preminted}, + {"burned", stats.Burned}, + {"staked", stats.Staked}, + } { + if f.amount.Atoms != "0" { + t.Fatalf("%s atoms = %q, want \"0\" (always present)", f.name, f.amount.Atoms) + } + } +} + +// --- Offset-based listings unchanged --- + +func TestOffsetListingsUnchanged(t *testing.T) { + pools := []indexer.Pool{{PoolID: "pool_escrow1qzz"}} + txs := []indexer.Transaction{{ID: "0f96"}} + + poolSrv := jsonHandler(t, pools) + defer poolSrv.Close() + txSrv := jsonHandler(t, txs) + defer txSrv.Close() + + gotPools, err := indexer.New(poolSrv.URL).ListPools(context.Background(), + indexer.PoolListOpts{PageOpts: indexer.PageOpts{Offset: 5, Items: 10}}) + if err != nil { + t.Fatalf("ListPools: %v", err) + } + if len(gotPools) != 1 || gotPools[0].PoolID != pools[0].PoolID { + t.Fatalf("ListPools = %v", gotPools) + } + + gotTxs, err := indexer.New(txSrv.URL).ListTransactions(context.Background(), indexer.PageOpts{Offset: 5, Items: 10}) + if err != nil { + t.Fatalf("ListTransactions: %v", err) + } + if len(gotTxs) != 1 || gotTxs[0].ID != txs[0].ID { + t.Fatalf("ListTransactions = %v", gotTxs) + } +} + +// --- Cycle-2 review hardening --- + +// TestPagerEmptyNextCursorEndsWalk pins the invariant that a server-issued +// empty cursor ("next_cursor": "") is end-of-listing, never a continuation +// position (which would re-serve page 1 forever). +func TestPagerEmptyNextCursorEndsWalk(t *testing.T) { + srv, reqs := routedServer(t, []route{ + {cursor: "", file: "holders_page_empty_cursor.json"}, + }) + c := indexer.New(srv.URL) + + pager := indexer.CoinHoldersPager(c) + count := 0 + err := pager.Walk(context.Background(), func(indexer.Holder) bool { + count++ + return true + }) + if err != nil { + t.Fatalf("Walk: %v", err) + } + if count != 1 { + t.Fatalf("walked %d holders, want 1", count) + } + if pager.NextCursor() != nil || pager.Truncated() { + t.Fatalf("empty next_cursor reported as cursor=%v truncated=%v", pager.NextCursor(), pager.Truncated()) + } + if len(*reqs) != 1 { + t.Fatalf("made %d requests, want 1 (no continuation attempt)", len(*reqs)) + } +} + +// TestPoolsPagerRejectsNonDefaultSort covers the pools-specific constructor +// rule (cursor walks require the default creation-height sort). +func TestPoolsPagerRejectsNonDefaultSort(t *testing.T) { + srv := jsonHandler(t, map[string]any{}) + defer srv.Close() + c := indexer.New(srv.URL) + ctx := context.Background() + + pager := indexer.PoolsPager(c, indexer.WithSort(indexer.SortByPledge)) + var reqErr *indexer.RequestError + _, err := pager.NextPage(ctx) + if !errors.As(err, &reqErr) || reqErr.Option != "WithSort" { + t.Fatalf("err = %v, want RequestError{WithSort}", err) + } + // The default and explicit by_height sorts remain valid. + for _, sort := range []string{"", indexer.SortByHeight} { + opts := []indexer.ListOption{} + if sort != "" { + opts = append(opts, indexer.WithSort(sort)) + } + if p := indexer.PoolsPager(c, opts...); p == nil { + t.Fatalf("PoolsPager(sort=%q) = nil", sort) + } + } +} + +// TestTransactionsPagerRejectsOffsetMode covers the transactions-specific +// constructor rule (cursor walks cannot be combined with an offset mode). +func TestTransactionsPagerRejectsOffsetMode(t *testing.T) { + srv := jsonHandler(t, map[string]any{}) + defer srv.Close() + c := indexer.New(srv.URL) + ctx := context.Background() + + pager := indexer.TransactionsPager(c, indexer.WithOffsetMode(indexer.OffsetModeAbsolute)) + var reqErr *indexer.RequestError + _, err := pager.NextPage(ctx) + if !errors.As(err, &reqErr) || reqErr.Option != "WithOffsetMode" { + t.Fatalf("err = %v, want RequestError{WithOffsetMode}", err) + } +} + +// TestHoldersPagerConstructorValidation covers the holder pagers rejecting +// side/sort/offset-mode options at construction, named after the pager. +func TestHoldersPagerConstructorValidation(t *testing.T) { + srv := jsonHandler(t, map[string]any{}) + defer srv.Close() + c := indexer.New(srv.URL) + ctx := context.Background() + + if _, err := indexer.CoinHoldersPager(c, indexer.WithSide(indexer.SideAsk)).NextPage(ctx); err == nil { + t.Fatal("CoinHoldersPager accepted WithSide") + } else { + var reqErr *indexer.RequestError + if !errors.As(err, &reqErr) || reqErr.Option != "WithSide" { + t.Fatalf("err = %v, want RequestError{WithSide}", err) + } + } + if _, err := indexer.TokenHoldersPager(c, "tok", indexer.WithOffsetMode(indexer.OffsetModeLegacy)).NextPage(ctx); err == nil { + t.Fatal("TokenHoldersPager accepted WithOffsetMode") + } else { + var reqErr *indexer.RequestError + if !errors.As(err, &reqErr) || reqErr.Option != "WithOffsetMode" { + t.Fatalf("err = %v, want RequestError{WithOffsetMode}", err) + } + } + if _, err := indexer.TokenHoldersPager(c, "tok", indexer.WithSort(indexer.SortByPledge)).NextPage(ctx); err == nil { + t.Fatal("TokenHoldersPager accepted WithSort") + } +} + +// TestTypedErrorsGatedOnStatus pins that message-based kind matching only +// fires on the documented status codes: a 500 body reading +// {"error":"Invalid cursor"} degrades to ErrorKindOther and does not match +// ErrInvalidCursor. +func TestTypedErrorsGatedOnStatus(t *testing.T) { + cases := []struct { + name string + status int + body string + wantKind indexer.ErrorKind + wantCursor bool // errors.Is(err, ErrInvalidCursor) + }{ + {"400 invalid cursor", 400, `{"error":"Invalid cursor"}`, indexer.ErrorKindInvalidCursor, true}, + {"500 invalid cursor body", 500, `{"error":"Invalid cursor"}`, indexer.ErrorKindOther, false}, + {"404 invalid cursor body", 404, `{"error":"Invalid cursor"}`, indexer.ErrorKindOther, false}, + {"404 token not found", 404, `{"error":"Token not found"}`, indexer.ErrorKindTokenNotFound, false}, + {"500 token not found body", 500, `{"error":"Token not found"}`, indexer.ErrorKindOther, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + srv := errorHandler(t, tc.status, tc.body) + defer srv.Close() + _, err := indexer.New(srv.URL).GetCoinStatistics(context.Background()) + var httpErr *indexer.HTTPError + if !errors.As(err, &httpErr) { + t.Fatalf("err = %v, want *indexer.HTTPError", err) + } + if httpErr.Kind != tc.wantKind { + t.Fatalf("Kind = %v, want %v", httpErr.Kind, tc.wantKind) + } + if got := errors.Is(err, indexer.ErrInvalidCursor); got != tc.wantCursor { + t.Fatalf("errors.Is(ErrInvalidCursor) = %v, want %v", got, tc.wantCursor) + } + if tc.status == 404 && tc.wantKind == indexer.ErrorKindTokenNotFound { + if !errors.Is(err, indexer.ErrTokenNotFound) { + t.Fatal("errors.Is(ErrTokenNotFound) = false, want true") + } + } + }) + } +} + +// TestNewPagerNilFetch pins that a nil fetch function surfaces as a deferred +// error on first use instead of panicking. +func TestNewPagerNilFetch(t *testing.T) { + pager := indexer.NewPager[indexer.Holder](nil) + if _, err := pager.NextPage(context.Background()); err == nil { + t.Fatal("NewPager(nil) NextPage, want deferred error") + } + if err := pager.Walk(context.Background(), func(indexer.Holder) bool { return true }); err == nil { + t.Fatal("NewPager(nil) Walk, want deferred error") + } +} + +// TestPagerNextPageServesRemainder pins that NextPage returns the unserved +// remainder of a partially consumed page before fetching the next one, so +// mixed Walk/NextPage usage cannot silently drop entries. +func TestPagerNextPageServesRemainder(t *testing.T) { + srv, reqs := routedServer(t, []route{ + {cursor: "", file: "holders_page1.json"}, + {cursor: "holders-cursor-2", file: "holders_page2.json"}, + }) + c := indexer.New(srv.URL) + ctx := context.Background() + + pager := indexer.CoinHoldersPager(c) + // Consume one item, then stop mid-page. + served := 0 + err := pager.Walk(ctx, func(indexer.Holder) bool { + served++ + return false + }) + if err != nil { + t.Fatalf("Walk: %v", err) + } + if served != 1 { + t.Fatalf("served %d items before stopping, want 1", served) + } + + // The first NextPage returns the remainder of page 1, not a new fetch. + remainder, err := pager.NextPage(ctx) + if err != nil { + t.Fatalf("NextPage remainder: %v", err) + } + if len(remainder) != 1 { + t.Fatalf("remainder = %d items, want 1", len(remainder)) + } + if len(*reqs) != 1 { + t.Fatalf("made %d requests after remainder, want 1 (no fetch yet)", len(*reqs)) + } + + // The following NextPage fetches page 2; after that the walk is finished. + page2, err := pager.NextPage(ctx) + if err != nil { + t.Fatalf("NextPage page2: %v", err) + } + if len(page2) != 1 { + t.Fatalf("page2 = %d items, want 1", len(page2)) + } + if page, err := pager.NextPage(ctx); page != nil || err != nil { + t.Fatalf("NextPage after end = (%v, %v), want (nil, nil)", page, err) + } +} + +// TestPagerNextPageThenWalk pins that a pager driven by NextPage and then +// handed to Walk does not re-serve items the NextPage caller already received: +// Walk resumes with the page after the one NextPage handed out whole. +func TestPagerNextPageThenWalk(t *testing.T) { + srv, reqs := routedServer(t, []route{ + {cursor: "", file: "holders_page1.json"}, + {cursor: "holders-cursor-2", file: "holders_page2.json"}, + }) + c := indexer.New(srv.URL) + ctx := context.Background() + + pager := indexer.CoinHoldersPager(c) + first, err := pager.NextPage(ctx) + if err != nil { + t.Fatalf("NextPage: %v", err) + } + if len(first) != 2 { + t.Fatalf("first page = %d items, want 2", len(first)) + } + + // Walk must continue after the items already handed out, not restart. + var seen []string + err = pager.Walk(ctx, func(h indexer.Holder) bool { + seen = append(seen, h.Address) + return true + }) + if err != nil { + t.Fatalf("Walk: %v", err) + } + if len(seen) != 1 { + t.Fatalf("Walk served %d items after a full NextPage handout, want 1 (page 2 only): %v", len(seen), seen) + } + if len(*reqs) != 2 { + t.Fatalf("made %d requests, want 2 (no re-fetch of page 1)", len(*reqs)) + } +} + +// TestPagerZeroValueNoPanic pins that a zero-value Pager (bypassing +// NewPager) surfaces a deferred RequestError on first use instead of +// panicking on the nil fetch function. +func TestPagerZeroValueNoPanic(t *testing.T) { + pager := &indexer.Pager[indexer.Pool]{} + if _, err := pager.NextPage(context.Background()); err == nil { + t.Fatal("NextPage on zero-value Pager = nil error, want RequestError") + } + if err := pager.Walk(context.Background(), func(indexer.Pool) bool { return true }); err == nil { + t.Fatal("Walk on zero-value Pager = nil error, want error") + } +} + +// TestTokenHoldersEmptyTokenID pins the client-side rejection of an empty +// token id on both the single-page method and the pager constructor. +func TestTokenHoldersEmptyTokenID(t *testing.T) { + srv, reqs := routedServer(t, nil) + c := indexer.New(srv.URL) + ctx := context.Background() + + if _, err := c.ListTokenHolders(ctx, ""); err == nil { + t.Fatal("ListTokenHolders with empty token id = nil error, want RequestError") + } + pager := indexer.TokenHoldersPager(c, "") + if _, err := pager.NextPage(ctx); err == nil { + t.Fatal("TokenHoldersPager with empty token id: NextPage = nil error, want RequestError") + } + if len(*reqs) != 0 { + t.Fatalf("made %d requests, want 0 (rejected client-side)", len(*reqs)) + } +} + +// TestPagerNilItemsWithCursor pins that a fetched page with a null/empty items +// array is normalized to a non-nil slice, so page == nil uniquely means the +// walk is finished even when the server still issued a cursor. +func TestPagerNilItemsWithCursor(t *testing.T) { + srv, reqs := routedServer(t, []route{ + {cursor: "", file: "holders_page_empty_items.json"}, + {cursor: "holders-cursor-empty-items", file: "holders_page2.json"}, + }) + c := indexer.New(srv.URL) + ctx := context.Background() + + pager := indexer.CoinHoldersPager(c) + page, err := pager.NextPage(ctx) + if err != nil { + t.Fatalf("NextPage: %v", err) + } + if page == nil { + t.Fatal("empty-items page returned nil, which would read as exhaustion") + } + if len(page) != 0 { + t.Fatalf("page = %d items, want 0", len(page)) + } + page2, err := pager.NextPage(ctx) + if err != nil { + t.Fatalf("NextPage page2: %v", err) + } + if page2 == nil || len(page2) != 1 { + t.Fatalf("page2 = (%v), want 1 item", page2) + } + if len(*reqs) != 2 { + t.Fatalf("made %d requests, want 2", len(*reqs)) + } +} diff --git a/indexer/pool.go b/indexer/pool.go index 50e9b03..e4bd71a 100644 --- a/indexer/pool.go +++ b/indexer/pool.go @@ -27,6 +27,82 @@ func (c *Client) ListPools(ctx context.Context, opts PoolListOpts) ([]Pool, erro return result, nil } +// poolsQuery renders the listing query for the cursor-paginated pools path. +// Only the default creation-height sort (SortByHeight, the server default) +// supports cursors: with any other sort value an explicit cursor is rejected +// by the server with 400 "Bad request", which the SDK propagates. Without an +// explicit cursor a non-default sort takes the offset-based path (bare array, +// no cursor), so a walk over it ends after one page. +func poolsQuery(p *listParams) url.Values { + forceCursor := p.cursor != "" || p.sort == "" || p.sort == SortByHeight + q := p.listQuery(forceCursor) + if p.sort != "" { + q.Set("sort", p.sort) + } + return q +} + +// fetchPools fetches one page of the pools listing at p's cursor position. +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)) + return pageResult(page, err) +} + +// ListPoolsPage returns one page of staking pools (newest creation height +// first by default) as a cursor page; pass NextCursor to WithCursor (or use +// PoolsPager) to continue the walk. Unlike ListPools, this method always uses +// the cursor envelope (except for a non-default sort without an explicit +// cursor — see WithSort). +// +// The default sort is the only cursor-compatible one: combining WithCursor +// (or the walk of a pager) with WithSort(SortByPledge) is rejected by the +// server with 400 "Bad request". +// +// When WithOffset is set without a cursor, the cursor silently overrides the +// offset page position server-side (items still applies); the SDK sends both +// parameters and does not alter that behaviour. +// +// Page stability is only guaranteed once the indexer's scanner is fully caught +// up; a walk during catch-up or a reorg may skip or repeat an entry. +func (c *Client) ListPoolsPage(ctx context.Context, opts ...ListOption) (*CursorPage[Pool], error) { + p, err := applyListOptions(opts) + if err != nil { + return nil, err + } + items, next, _, err := c.fetchPools(ctx, p) + if err != nil { + return nil, err + } + return &CursorPage[Pool]{Items: items, NextCursor: next}, nil +} + +// PoolsPager returns a pager that walks the pools listing (newest creation +// height first by default), following the server cursor automatically. The +// walk only works with the default creation-height sort; see ListPoolsPage +// and WithSort for the sort/cursor restriction. +func PoolsPager(c *Client, opts ...ListOption) *Pager[Pool] { + p, err := applyListOptions(opts) + if err != nil { + return fail[Pool](err) + } + if err := p.reject("PoolsPager", paramSide|paramOffsetMode); err != nil { + return fail[Pool](err) + } + if p.sort != "" && p.sort != SortByHeight { + return fail[Pool](&RequestError{Option: "WithSort", Reason: "pools cursor walks require the default by_height sort; use ListPoolsPage for other sort orders"}) + } + return NewPager(func(ctx context.Context, cursor *string) ([]Pool, *string, bool, error) { + fetch := p + if cursor != nil { + fetch = cloneParams(fetch, *cursor) + } + return c.fetchPools(ctx, fetch) + }) +} + // GetPool returns a single staking pool by id (bech32). func (c *Client) GetPool(ctx context.Context, id string) (*Pool, error) { var result Pool diff --git a/indexer/statistics.go b/indexer/statistics.go index a46eba8..ab4848f 100644 --- a/indexer/statistics.go +++ b/indexer/statistics.go @@ -44,3 +44,101 @@ func (c *Client) GetFeeRate(ctx context.Context, inTopXMb uint32) (string, error } return result, nil } + +// fetchCoinHolders fetches one page of the native coin holders listing. +func (c *Client) fetchCoinHolders(ctx context.Context, p *listParams) ([]Holder, *string, bool, error) { + if err := p.reject("ListCoinHolders", paramSide|paramSort|paramOffsetMode); err != nil { + return nil, nil, false, err + } + // The listing is keyset-only: the server parses but ignores the offset, and + // a present cursor silently overrides the page position. The SDK sends the + // parameters as-is and leaves the server behaviour untouched. + page, err := getPage[Holder](ctx, c, "/statistics/coin/holders", p.listQuery(true)) + return pageResult(page, err) +} + +// fetchTokenHolders fetches one page of a token's holders listing. +func (c *Client) fetchTokenHolders(ctx context.Context, tokenID string, p *listParams) ([]Holder, *string, bool, error) { + if err := p.reject("ListTokenHolders", paramSide|paramSort|paramOffsetMode); err != nil { + return nil, nil, false, err + } + if tokenID == "" { + return nil, nil, false, &RequestError{Option: "tokenID", Reason: "must not be empty"} + } + page, err := getPage[Holder](ctx, c, fmt.Sprintf("/statistics/token/%s/holders", url.PathEscape(tokenID)), p.listQuery(true)) + return pageResult(page, err) +} + +// ListCoinHolders returns one page of the native coin holders, largest +// balance first, as an address-balance listing. The result is a cursor page: +// pass NextCursor to WithCursor (or use CoinHoldersPager) to continue the +// walk. There is no offset mode for this endpoint — the server ignores the +// offset parameter, and the cursor defines the page position. +// +// Page stability is only guaranteed once the indexer's scanner is fully caught +// up; a walk during catch-up or a reorg may skip or repeat an entry. +func (c *Client) ListCoinHolders(ctx context.Context, opts ...ListOption) (*CursorPage[Holder], error) { + p, err := applyListOptions(opts) + if err != nil { + return nil, err + } + items, next, _, err := c.fetchCoinHolders(ctx, p) + if err != nil { + return nil, err + } + return &CursorPage[Holder]{Items: items, NextCursor: next}, nil +} + +// ListTokenHolders returns one page of the holders of the token with the given +// id (bech32, case-sensitive — pass it exactly as issued; the SDK never +// changes its case). Unknown token ids fail with an *HTTPError matching +// ErrTokenNotFound. See ListCoinHolders for paging semantics. +func (c *Client) ListTokenHolders(ctx context.Context, tokenID string, opts ...ListOption) (*CursorPage[Holder], error) { + p, err := applyListOptions(opts) + if err != nil { + return nil, err + } + items, next, _, err := c.fetchTokenHolders(ctx, tokenID, p) + if err != nil { + return nil, err + } + return &CursorPage[Holder]{Items: items, NextCursor: next}, nil +} + +// CoinHoldersPager returns a pager that walks the native coin holders listing +// (largest balance first), following the server cursor automatically. +func CoinHoldersPager(c *Client, opts ...ListOption) *Pager[Holder] { + p, err := applyListOptions(opts) + if err != nil { + return fail[Holder](err) + } + if err := p.reject("CoinHoldersPager", paramSide|paramSort|paramOffsetMode); err != nil { + return fail[Holder](err) + } + return NewPager(func(ctx context.Context, cursor *string) ([]Holder, *string, bool, error) { + fetch := p + if cursor != nil { + fetch = cloneParams(fetch, *cursor) + } + return c.fetchCoinHolders(ctx, fetch) + }) +} + +// TokenHoldersPager returns a pager that walks the holders listing of the +// token with the given id (bech32, case-sensitive). +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) { + fetch := p + if cursor != nil { + fetch = cloneParams(fetch, *cursor) + } + return c.fetchTokenHolders(ctx, tokenID, fetch) + }) +} diff --git a/indexer/testdata/book_asks_page1.json b/indexer/testdata/book_asks_page1.json new file mode 100644 index 0000000..0aa1d53 --- /dev/null +++ b/indexer/testdata/book_asks_page1.json @@ -0,0 +1,7 @@ +{ + "items": [ + { "price": { "atoms": "1/3", "decimal": "0.333333333" }, "amount": { "atoms": "5000000000", "decimal": "5.000000000" } }, + { "price": { "atoms": "2/3", "decimal": "0.666666666" }, "amount": { "atoms": "1000000000", "decimal": "1.000000000" } } + ], + "next_cursor": "book-ask-cursor-2" +} diff --git a/indexer/testdata/book_asks_page2.json b/indexer/testdata/book_asks_page2.json new file mode 100644 index 0000000..83bd0cf --- /dev/null +++ b/indexer/testdata/book_asks_page2.json @@ -0,0 +1,6 @@ +{ + "items": [ + { "price": { "atoms": "1", "decimal": "1.000000000" }, "amount": { "atoms": "7500000000", "decimal": "7.500000000" } } + ], + "next_cursor": null +} diff --git a/indexer/testdata/book_asks_truncated.json b/indexer/testdata/book_asks_truncated.json new file mode 100644 index 0000000..072f0e0 --- /dev/null +++ b/indexer/testdata/book_asks_truncated.json @@ -0,0 +1,7 @@ +{ + "items": [ + { "price": { "atoms": "1/2", "decimal": "0.500000000" }, "amount": { "atoms": "2500000000", "decimal": "2.500000000" } } + ], + "truncated": true, + "next_cursor": null +} diff --git a/indexer/testdata/book_bids_page1.json b/indexer/testdata/book_bids_page1.json new file mode 100644 index 0000000..1e95a2a --- /dev/null +++ b/indexer/testdata/book_bids_page1.json @@ -0,0 +1,7 @@ +{ + "items": [ + { "price": { "atoms": "3/2", "decimal": "1.500000000" }, "amount": { "atoms": "3000000000", "decimal": "3.000000000" } }, + { "price": { "atoms": "4/3", "decimal": "1.333333333" }, "amount": { "atoms": "6000000000", "decimal": "6.000000000" } } + ], + "next_cursor": null +} diff --git a/indexer/testdata/holders_page1.json b/indexer/testdata/holders_page1.json new file mode 100644 index 0000000..5f94028 --- /dev/null +++ b/indexer/testdata/holders_page1.json @@ -0,0 +1,7 @@ +{ + "items": [ + { "address": "address1qcoinholder000000000000000000000000000000000000000aaaaaa", "amount": { "atoms": "1500000000000", "decimal": "1500.000000000" } }, + { "address": "address1qcoinholder000000000000000000000000000000000000000bbbbbb", "amount": { "atoms": "1200000000000", "decimal": "1200.000000000" } } + ], + "next_cursor": "holders-cursor-2" +} diff --git a/indexer/testdata/holders_page2.json b/indexer/testdata/holders_page2.json new file mode 100644 index 0000000..facc630 --- /dev/null +++ b/indexer/testdata/holders_page2.json @@ -0,0 +1,6 @@ +{ + "items": [ + { "address": "address1qcoinholder000000000000000000000000000000000000000cccccc", "amount": { "atoms": "900000000000", "decimal": "900.000000000" } } + ], + "next_cursor": null +} diff --git a/indexer/testdata/holders_page_empty_cursor.json b/indexer/testdata/holders_page_empty_cursor.json new file mode 100644 index 0000000..e73e9cb --- /dev/null +++ b/indexer/testdata/holders_page_empty_cursor.json @@ -0,0 +1,9 @@ +{ + "items": [ + { + "address": "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx", + "amount": { "atoms": "1000000", "decimal": "0.01" } + } + ], + "next_cursor": "" +} diff --git a/indexer/testdata/holders_page_empty_items.json b/indexer/testdata/holders_page_empty_items.json new file mode 100644 index 0000000..2fffc23 --- /dev/null +++ b/indexer/testdata/holders_page_empty_items.json @@ -0,0 +1,4 @@ +{ + "items": [], + "next_cursor": "holders-cursor-empty-items" +} diff --git a/indexer/testdata/pools_offset.json b/indexer/testdata/pools_offset.json new file mode 100644 index 0000000..9c2b40d --- /dev/null +++ b/indexer/testdata/pools_offset.json @@ -0,0 +1,11 @@ +[ + { + "pool_id": "pool_escrow1qpledge0pfsx4qr9vnsc3wm8xklj62fv2cldsd7ljmw6c3gcdm9a3374aa", + "decommission_destination": "address1q9li0gt3cycwvugy2gykstrskx525jldpqfu9szzzz", + "staker_balance": { "atoms": "99000000000000", "decimal": "99000.000000000" }, + "margin_ratio_per_thousand": "5000", + "cost_per_block": { "atoms": "100000000", "decimal": "0.100000000" }, + "vrf_public_key": "0334000000000000000000000000000000000000000000000000000000000000dd", + "delegations_balance": { "atoms": "0", "decimal": "0.000000000" } + } +] diff --git a/indexer/testdata/pools_page1.json b/indexer/testdata/pools_page1.json new file mode 100644 index 0000000..b6701d2 --- /dev/null +++ b/indexer/testdata/pools_page1.json @@ -0,0 +1,23 @@ +{ + "items": [ + { + "pool_id": "pool_escrow1qzznn2pfsx4qr9vnsc3wm8xklj62fv2cldsd7ljmw6c3gcdm9a3374axkgz", + "decommission_destination": "address1q9li0gt3cycwvugy2gykstrskx525jldpqfu9sxxxx", + "staker_balance": { "atoms": "90000000000000", "decimal": "90000.000000000" }, + "margin_ratio_per_thousand": "5000", + "cost_per_block": { "atoms": "100000000", "decimal": "0.100000000" }, + "vrf_public_key": "033400000000000000000000000000000000000000000000000000000000000000aa", + "delegations_balance": { "atoms": "1000000000000", "decimal": "1000.000000000" } + }, + { + "pool_id": "pool_escrow1qxyz2pfsx4qr9vnsc3wm8xklj62fv2cldsd7ljmw6c3gcdm9a3374a0000", + "decommission_destination": "address1q9li0gt3cycwvugy2gykstrskx525jldpqfu9s", + "staker_balance": { "atoms": "80000000000000", "decimal": "80000.000000000" }, + "margin_ratio_per_thousand": "10000", + "cost_per_block": { "atoms": "50000000", "decimal": "0.050000000" }, + "vrf_public_key": "0334000000000000000000000000000000000000000000000000000000000000bb", + "delegations_balance": { "atoms": "0", "decimal": "0.000000000" } + } + ], + "next_cursor": "pools-cursor-2" +} diff --git a/indexer/testdata/pools_page2.json b/indexer/testdata/pools_page2.json new file mode 100644 index 0000000..35cd442 --- /dev/null +++ b/indexer/testdata/pools_page2.json @@ -0,0 +1,14 @@ +{ + "items": [ + { + "pool_id": "pool_escrow1qlast2pfsx4qr9vnsc3wm8xklj62fv2cldsd7ljmw6c3gcdm9a3374zzzz", + "decommission_destination": "address1q9li0gt3cycwvugy2gykstrskx525jldpqfu9syyyy", + "staker_balance": { "atoms": "70000000000000", "decimal": "70000.000000000" }, + "margin_ratio_per_thousand": "0", + "cost_per_block": { "atoms": "10000000", "decimal": "0.010000000" }, + "vrf_public_key": "0334000000000000000000000000000000000000000000000000000000000000cc", + "delegations_balance": { "atoms": "250000000000", "decimal": "250.000000000" } + } + ], + "next_cursor": null +} diff --git a/indexer/testdata/transactions_offset.json b/indexer/testdata/transactions_offset.json new file mode 100644 index 0000000..7325d9b --- /dev/null +++ b/indexer/testdata/transactions_offset.json @@ -0,0 +1,10 @@ +[ + { + "id": "aaaabbbbccccdddd0000000000000000000000000000000000000000000000aa", + "inputs": [], + "outputs": [], + "block_id": "cc3356bd8f83b02f0f5e4b1c1a2d3e4f5061728394a5b6c7d8e9f0a1b2c3d4efa", + "timestamp": "1739500000", + "confirmations": "56" + } +] diff --git a/indexer/testdata/transactions_page1.json b/indexer/testdata/transactions_page1.json new file mode 100644 index 0000000..708ba9c --- /dev/null +++ b/indexer/testdata/transactions_page1.json @@ -0,0 +1,21 @@ +{ + "items": [ + { + "id": "0f966c8a1b2c3d4e5f60718293a4b5c6d7e8f900123456789abcdef0123456789", + "inputs": [{ "input": { "type": "UTXO", "id": "1122334455667788", "index": 0 } }], + "outputs": [{ "output": { "type": "Transfer", "destination": "address1q9li0gt3cycwvugy2gykstrskx525jldpqfu9s", "amount": { "atoms": "100000000", "decimal": "0.100000000" } } }], + "block_id": "aa11456bd8f83b02f0f5e4b1c1a2d3e4f5061728394a5b6c7d8e9f0a1b2c3d4e", + "timestamp": "1739520000", + "confirmations": "12" + }, + { + "id": "ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100", + "inputs": [], + "outputs": [], + "block_id": null, + "timestamp": "1739520100", + "confirmations": "0" + } + ], + "next_cursor": "tx-cursor-2" +} diff --git a/indexer/testdata/transactions_page2.json b/indexer/testdata/transactions_page2.json new file mode 100644 index 0000000..e2ea8ef --- /dev/null +++ b/indexer/testdata/transactions_page2.json @@ -0,0 +1,13 @@ +{ + "items": [ + { + "id": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "inputs": [], + "outputs": [], + "block_id": "bb22456bd8f83b02f0f5e4b1c1a2d3e4f5061728394a5b6c7d8e9f0a1b2c3d4ef", + "timestamp": "1739510000", + "confirmations": "34" + } + ], + "next_cursor": null +} diff --git a/indexer/transaction.go b/indexer/transaction.go index e4b0782..0e1d43b 100644 --- a/indexer/transaction.go +++ b/indexer/transaction.go @@ -10,6 +10,7 @@ import ( "context" "encoding/json" "fmt" + "net/url" ) // ListTransactions returns paginated transactions. @@ -21,6 +22,75 @@ func (c *Client) ListTransactions(ctx context.Context, opts PageOpts) ([]Transac return result, nil } +// transactionsQuery renders the listing query for the global transaction path. +// offset_mode selects the offset-based listing (no cursors); a cursor combined +// with offset_mode is rejected by the server with 400 "Bad request", which the +// SDK propagates when an explicit cursor is requested alongside it. +func transactionsQuery(p *listParams) url.Values { + forceCursor := p.cursor != "" || p.offsetMode == "" + q := p.listQuery(forceCursor) + if p.offsetMode != "" { + q.Set("offset_mode", p.offsetMode) + } + return q +} + +// fetchTransactions fetches one page of the global transaction listing. +func (c *Client) fetchTransactions(ctx context.Context, p *listParams) ([]Transaction, *string, bool, error) { + if err := p.reject("ListTransactionsPage", paramSide|paramSort); err != nil { + return nil, nil, false, err + } + page, err := getPage[Transaction](ctx, c, "/transaction", transactionsQuery(p)) + return pageResult(page, err) +} + +// ListTransactionsPage returns one page of the global transaction listing as a +// cursor page; pass NextCursor to WithCursor (or use TransactionsPager) to +// continue the walk. The listing is ordered newest block first, transactions +// in block order within each block; Transaction.BlockID carries the hash of +// the confirming block ("" for pending/mempool transactions). +// +// Combining WithCursor with WithOffsetMode is rejected by the server with +// 400 "Bad request". WithOffsetMode (legacy or absolute) selects the +// offset-based listing instead, which has no cursors — such a page arrives as +// a bare array and is returned with a nil NextCursor. Per-block transaction +// listings remain offset-based (see the block endpoints). +// +// Page stability is only guaranteed once the indexer's scanner is fully caught +// up; a walk during catch-up or a reorg may skip or repeat an entry. +func (c *Client) ListTransactionsPage(ctx context.Context, opts ...ListOption) (*CursorPage[Transaction], error) { + p, err := applyListOptions(opts) + if err != nil { + return nil, err + } + items, next, _, err := c.fetchTransactions(ctx, p) + if err != nil { + return nil, err + } + return &CursorPage[Transaction]{Items: items, NextCursor: next}, nil +} + +// TransactionsPager returns a pager that walks the global transaction listing +// (newest block first, transactions in block order within each block), +// following the server cursor automatically. It cannot be combined with +// WithOffsetMode; see ListTransactionsPage. +func TransactionsPager(c *Client, opts ...ListOption) *Pager[Transaction] { + p, err := applyListOptions(opts) + if err != nil { + return fail[Transaction](err) + } + if err := p.reject("TransactionsPager", paramSide|paramSort|paramOffsetMode); err != nil { + return fail[Transaction](err) + } + return NewPager(func(ctx context.Context, cursor *string) ([]Transaction, *string, bool, error) { + fetch := p + if cursor != nil { + fetch = cloneParams(fetch, *cursor) + } + return c.fetchTransactions(ctx, fetch) + }) +} + // GetTransaction returns the transaction with the given id (hex). // BlockID, Timestamp, and Confirmations are empty strings if the transaction // is not yet confirmed. diff --git a/indexer/types.go b/indexer/types.go index b4fefef..474cf0f 100644 --- a/indexer/types.go +++ b/indexer/types.go @@ -81,7 +81,9 @@ type Block struct { } // Transaction is returned by GetTransaction and ListTransactions. -// BlockID, Timestamp, and Confirmations are empty strings for unconfirmed transactions. +// BlockID is the hash of the block that confirmed the transaction (not the +// block height); for unconfirmed (mempool) transactions BlockID, Timestamp, +// and Confirmations are empty strings. type Transaction struct { ID string `json:"id"` Inputs json.RawMessage `json:"inputs"` @@ -136,13 +138,13 @@ type DelegationInfo struct { // Pool is one entry returned by ListPools and GetPool. type Pool struct { - PoolID string `json:"pool_id"` - DecommissionDestination string `json:"decommission_destination"` - StakerBalance Amount `json:"staker_balance"` + PoolID string `json:"pool_id"` + DecommissionDestination string `json:"decommission_destination"` + StakerBalance Amount `json:"staker_balance"` MarginRatioPerThousand PerThousand `json:"margin_ratio_per_thousand"` - CostPerBlock Amount `json:"cost_per_block"` - VRFPublicKey string `json:"vrf_public_key"` - DelegationsBalance Amount `json:"delegations_balance"` + CostPerBlock Amount `json:"cost_per_block"` + VRFPublicKey string `json:"vrf_public_key"` + DelegationsBalance Amount `json:"delegations_balance"` } // Delegation is returned by GetDelegation. @@ -221,6 +223,9 @@ type Order struct { } // CoinStats is returned by GetCoinStatistics and GetTokenStatistics. +// All four counters are always present in api-server v2 (1.4.1 and later): +// a counter that has never been written renders as zero (atoms "0"), so +// clients may read the fields directly without nil/emptiness checks. type CoinStats struct { CirculatingSupply Amount `json:"circulating_supply"` Preminted Amount `json:"preminted"` @@ -228,6 +233,56 @@ type CoinStats struct { Staked Amount `json:"staked"` } +// Holder is one entry of the coin/token holders listing +// (ListCoinHolders / ListTokenHolders). Amount.Decimal is rendered with the +// decimals of the listed currency: 9 for the native coin, the token's +// number_of_decimals for tokens. +type Holder struct { + Address string `json:"address"` + Amount Amount `json:"amount"` +} + +// OrderBookPrice is the aggregated price of an order-book level. +// Atoms is the exact price as a reduced rational number "/" +// (remaining quote atoms per remaining base atom, in atoms); Decimal is the +// server's decimal rendering of that rational, floored toward zero and scaled +// to the quote currency's decimals. Never parse or construct Atoms yourself: +// it is only meaningful as an opaque exact value. +type OrderBookPrice struct { + Atoms string `json:"atoms"` + Decimal string `json:"decimal"` +} + +// OrderBookLevel is one aggregated price level of an order book; Amount is +// the total remaining base-currency amount across all orders at Price. +type OrderBookLevel struct { + Price OrderBookPrice `json:"price"` + Amount Amount `json:"amount"` +} + +// OrderBookPage is one page of the order book (GetOrderBook / OrderBookPager). +// The exact rational Price.Atoms plus the floored Price.Decimal match the +// Amount conventions used across this package. +// +// Truncated is true when the server hit its per-request aggregation cap +// (OrderBookMaxOrders live orders scanned): Levels is an incomplete +// aggregation of the book, and — as an invariant — NextCursor is then always +// nil, because the walk cannot be continued from a partial aggregation. A +// non-truncated page with no further levels also has a nil NextCursor +// (end of book). Check Truncated before treating a nil NextCursor as "book +// fully consumed". +type OrderBookPage struct { + // Levels holds the aggregated price levels of this page, best price first + // (ascending for the ask side, descending for the bid side). + Levels []OrderBookLevel `json:"items"` + // Truncated is absent (false) in the JSON unless the server reported the + // aggregation-cap overflow. + Truncated bool `json:"truncated,omitempty"` + // NextCursor is the server-issued cursor of the next page, or nil at the + // end of the book — and always when Truncated is true. + NextCursor *string `json:"next_cursor"` +} + // pageQuery converts PageOpts to url.Values, omitting zero-value fields. func pageQuery(opts PageOpts) url.Values { q := url.Values{} From 91f82322f5d515435f4c67b4b5a52f890e10b11e Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Mon, 5 Oct 2026 15:06:32 +0200 Subject: [PATCH 2/8] fix(indexer): address PR review findings - 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 --- CHANGELOG.md | 2 +- docs/indexer.md | 4 +-- indexer/paginate.go | 2 ++ indexer/pagination_test.go | 60 +++++++++++++++++++++++++++++++++----- indexer/statistics.go | 3 ++ indexer/transaction.go | 7 +++-- 6 files changed, 66 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 02925c6..a71b353 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm Cursor pagination for the api-server v2 endpoints introduced in mintlayer-core PR #2130 (api-server 1.4.1): - **Pools**: `ListPoolsPage` and `PoolsPager` — cursor envelope over `/v2/pool`; cursors work only with the default `by_height` sort (any other sort with a cursor is a server 400 `Bad request`). -- **Transactions**: `ListTransactionsPage` and `TransactionsPager` — cursor envelope over the global listing (newest block first, transactions in block order within each block); `WithOffsetMode("legacy"|"absolute")` selects the offset-based listing instead, and cursor + `offset_mode` is a server 400. `Transaction.BlockID` is documented as the confirming block's hash (empty for mempool transactions). +- **Transactions**: `ListTransactionsPage` and `TransactionsPager` — cursor envelope over the global listing (newest block first, transactions in block order within each block); `WithOffsetMode("legacy"|"absolute")` selects the offset-based listing instead, and cursor + `offset_mode` is rejected client-side with a `*RequestError`. `Transaction.BlockID` is documented as the confirming block's hash (empty for mempool transactions). - **Holders**: `ListCoinHolders`, `ListTokenHolders`, `CoinHoldersPager`, `TokenHoldersPager`, and the `Holder` type (address + amount; token ids are exact case-sensitive bech32, unknown ids are 404 `Token not found`). - **Order book**: `GetOrderBook`, `OrderBookPager`, and the `OrderBookPage`/`OrderBookLevel`/`OrderBookPrice` types — aggregated price levels (exact reduced-rational price plus floored decimal rendering) per side; required `WithSide`, side-specific cursors, and the truncation invariant (`Truncated` true ⇒ `NextCursor` nil at the server's `OrderBookMaxOrders` = 10000-order cap; the paginator stops there). - **Shared paginator**: generic `Pager[T]` (`NewPager`, `NextPage`, `Walk`, `NextCursor`, `Truncated`) and the `CursorPage[T]` `{items, next_cursor}` envelope; pre-1.4.1 bare-array responses are accepted and wrapped, a server-issued empty cursor is treated as end-of-listing, and `NextPage` never loses the unserved remainder of a partially consumed page. diff --git a/docs/indexer.md b/docs/indexer.md index eec6015..6e904fa 100644 --- a/docs/indexer.md +++ b/docs/indexer.md @@ -68,7 +68,7 @@ Options: | `WithCursor(cur)` | all cursor endpoints | resume from a server-issued cursor. Cursors are opaque: never construct, decode, or modify one — a fabricated or cross-endpoint cursor is rejected with 400 `Invalid cursor`. | | `WithOffset(n)` | pools, transactions | offset of an offset-based listing. On the holders/order-book endpoints the cursor defines the page position server-side and the offset is ignored (`items` still applies); the SDK sends both parameters as-is. | | `WithSort(s)` | pools | `"by_height"` (default) or `"by_pledge"`; any other value fails client-side. Only the default creation-height sort supports cursors: any other sort value combined with a cursor is rejected by the server with 400 `Bad request`. | -| `WithOffsetMode(m)` | transactions | `"legacy"` (default) or `"absolute"`; selects the offset-based listing, which has no cursors. Combining a cursor with `offset_mode` is rejected by the server with 400 `Bad request`. | +| `WithOffsetMode(m)` | transactions | `"legacy"` (default) or `"absolute"`; selects the offset-based listing, which has no cursors. Combining a cursor with `offset_mode` is rejected client-side with a `*RequestError`. | | `WithSide(s)` | order book | `"ask"` or `"bid"` — required for the book. Cursors are side-specific (`book-ask` / `book-bid`): an ask cursor on a bid walk returns 400 `Invalid cursor`. | Invalid combinations and values fail client-side with `*indexer.RequestError` before any request is sent. Server-side rejections (400/404) are returned as `*indexer.HTTPError` with `Kind` and sentinel matching — see [Errors](#errors). @@ -172,7 +172,7 @@ Returns a paginated list of confirmed transactions across the entire chain, orde func (c *Client) ListTransactionsPage(ctx context.Context, opts ...ListOption) (*CursorPage[Transaction], error) ``` -One page of the global transaction listing as a cursor envelope; pass `NextCursor` to `WithCursor` (or use `TransactionsPager`) to continue the walk. `WithOffsetMode("legacy"|"absolute")` selects the offset-based listing instead, which has no cursors (such a page arrives with a nil `NextCursor`). Combining a cursor with `offset_mode` is rejected by the server with 400 `Bad request`. Per-block transaction listings remain offset-based (see `GetBlockTransactionIDs`). +One page of the global transaction listing as a cursor envelope; pass `NextCursor` to `WithCursor` (or use `TransactionsPager`) to continue the walk. `WithOffsetMode("legacy"|"absolute")` selects the offset-based listing instead, which has no cursors (such a page arrives with a nil `NextCursor`). Combining a cursor with `offset_mode` is rejected client-side with a `*RequestError`. Per-block transaction listings remain offset-based (see `GetBlockTransactionIDs`). ### `TransactionsPager` diff --git a/indexer/paginate.go b/indexer/paginate.go index b330af3..bf3f219 100644 --- a/indexer/paginate.go +++ b/indexer/paginate.go @@ -7,6 +7,7 @@ package indexer import ( + "bytes" "context" "encoding/json" "fmt" @@ -192,6 +193,7 @@ func getPage[T any](ctx context.Context, c *Client, path string, query url.Value if err != nil { return nil, err } + raw = bytes.TrimSpace(raw) if len(raw) > 0 && raw[0] == '[' { var items []T if err := json.Unmarshal(raw, &items); err != nil { diff --git a/indexer/pagination_test.go b/indexer/pagination_test.go index 1eeebdf..b037118 100644 --- a/indexer/pagination_test.go +++ b/indexer/pagination_test.go @@ -310,17 +310,23 @@ func TestTransactionsOffsetModeSinglePage(t *testing.T) { } } -func TestTransactionsCursorWithOffsetModeServerReject(t *testing.T) { - // cursor + offset_mode is a server 400 "Bad request", propagated as-is. - srv := errorHandler(t, http.StatusBadRequest, `{"error":"Bad request"}`) - defer srv.Close() +func TestTransactionsCursorWithOffsetModeClientReject(t *testing.T) { + // cursor + offset_mode is rejected client-side with a RequestError before + // any request is made. + srv, reqs := routedServer(t, nil) c := indexer.New(srv.URL) _, err := c.ListTransactionsPage(context.Background(), indexer.WithCursor("tx-cursor-2"), indexer.WithOffsetMode(indexer.OffsetModeLegacy)) - var httpErr *indexer.HTTPError - if !errors.As(err, &httpErr) || httpErr.StatusCode != http.StatusBadRequest || httpErr.Message != "Bad request" { - t.Fatalf("err = %v, want 400 Bad request", err) + var reqErr *indexer.RequestError + if !errors.As(err, &reqErr) { + t.Fatalf("err = %v, want *indexer.RequestError", err) + } + if reqErr.Option != "WithOffsetMode" { + t.Fatalf("RequestError.Option = %q, want WithOffsetMode", reqErr.Option) + } + if len(*reqs) != 0 { + t.Fatalf("made %d requests, want 0 (rejected client-side)", len(*reqs)) } } @@ -945,3 +951,43 @@ func TestPagerNilItemsWithCursor(t *testing.T) { t.Fatalf("made %d requests, want 2", len(*reqs)) } } + +// TestTokenHoldersPagerDeferredOptionError pins that an invalid option passed +// to TokenHoldersPager surfaces as a deferred RequestError on the first +// NextPage instead of a nil-pointer panic (applyListOptions fails, so the +// constructor must not capture a nil params pointer). +func TestTokenHoldersPagerDeferredOptionError(t *testing.T) { + srv, reqs := routedServer(t, nil) + c := indexer.New(srv.URL) + ctx := context.Background() + + pager := indexer.TokenHoldersPager(c, "ttml1qvalidaddress", indexer.WithItems(0)) + _, err := pager.NextPage(ctx) + var reqErr *indexer.RequestError + if !errors.As(err, &reqErr) { + t.Fatalf("err = %v, want *indexer.RequestError", err) + } + if len(*reqs) != 0 { + t.Fatalf("made %d requests, want 0 (rejected before any request)", len(*reqs)) + } +} + +// TestBareArrayLeadingWhitespace pins that a legacy bare-array response with +// leading whitespace still decodes as an offset page (sniffing happens after +// whitespace is trimmed, not on a zero-value envelope). +func TestBareArrayLeadingWhitespace(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, " %s ", readFixture(t, "pools_offset.json")) + })) + defer srv.Close() + c := indexer.New(srv.URL) + + pools, err := c.ListPools(context.Background(), indexer.PoolListOpts{}) + if err != nil { + t.Fatalf("ListPools: %v", err) + } + if len(pools) != 1 { + t.Fatalf("got %d pools, want 1", len(pools)) + } +} diff --git a/indexer/statistics.go b/indexer/statistics.go index ab4848f..cb8cc78 100644 --- a/indexer/statistics.go +++ b/indexer/statistics.go @@ -134,6 +134,9 @@ func TokenHoldersPager(c *Client, tokenID string, opts ...ListOption) *Pager[Hol if err == nil && tokenID == "" { err = &RequestError{Option: "tokenID", Reason: "must not be empty"} } + if err != nil { + return fail[Holder](err) + } return NewPager(func(ctx context.Context, cursor *string) ([]Holder, *string, bool, error) { fetch := p if cursor != nil { diff --git a/indexer/transaction.go b/indexer/transaction.go index 0e1d43b..72ff72e 100644 --- a/indexer/transaction.go +++ b/indexer/transaction.go @@ -40,6 +40,9 @@ func (c *Client) fetchTransactions(ctx context.Context, p *listParams) ([]Transa 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)) return pageResult(page, err) } @@ -50,8 +53,8 @@ func (c *Client) fetchTransactions(ctx context.Context, p *listParams) ([]Transa // in block order within each block; Transaction.BlockID carries the hash of // the confirming block ("" for pending/mempool transactions). // -// Combining WithCursor with WithOffsetMode is rejected by the server with -// 400 "Bad request". WithOffsetMode (legacy or absolute) selects the +// Combining WithCursor with WithOffsetMode is rejected client-side with a +// *RequestError. WithOffsetMode (legacy or absolute) selects the // offset-based listing instead, which has no cursors — such a page arrives as // a bare array and is returned with a nil NextCursor. Per-block transaction // listings remain offset-based (see the block endpoints). From 278311074474e58999be865111911602d6d55209 Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Mon, 5 Oct 2026 15:21:42 +0200 Subject: [PATCH 3/8] docs(indexer): disclose cursor-over-offset precedence on transaction listing --- docs/indexer.md | 2 +- indexer/options.go | 9 +++++---- indexer/transaction.go | 13 +++++++++---- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/docs/indexer.md b/docs/indexer.md index 6e904fa..63c2a5a 100644 --- a/docs/indexer.md +++ b/docs/indexer.md @@ -172,7 +172,7 @@ Returns a paginated list of confirmed transactions across the entire chain, orde func (c *Client) ListTransactionsPage(ctx context.Context, opts ...ListOption) (*CursorPage[Transaction], error) ``` -One page of the global transaction listing as a cursor envelope; pass `NextCursor` to `WithCursor` (or use `TransactionsPager`) to continue the walk. `WithOffsetMode("legacy"|"absolute")` selects the offset-based listing instead, which has no cursors (such a page arrives with a nil `NextCursor`). Combining a cursor with `offset_mode` is rejected client-side with a `*RequestError`. Per-block transaction listings remain offset-based (see `GetBlockTransactionIDs`). +One page of the global transaction listing as a cursor envelope; pass `NextCursor` to `WithCursor` (or use `TransactionsPager`) to continue the walk. `WithOffsetMode("legacy"|"absolute")` selects the offset-based listing instead, which has no cursors (such a page arrives with a nil `NextCursor`). Combining a cursor with `offset_mode` is rejected client-side with a `*RequestError`. In the default (cursor) mode the cursor parameter is always present (empty on a first page), so the server silently overrides the `WithOffset` page position with the cursor (`items` still applies) — use `WithOffsetMode` for offset-based pages. Per-block transaction listings remain offset-based (see `GetBlockTransactionIDs`). ### `TransactionsPager` diff --git a/indexer/options.go b/indexer/options.go index c167d38..1e8e807 100644 --- a/indexer/options.go +++ b/indexer/options.go @@ -96,10 +96,11 @@ func WithItems(items uint32) ListOption { } // WithOffset sets the offset of an offset-based listing. Only the transaction -// listing and the pools listing still honour an offset when no cursor is used; -// everywhere else the cursor defines the page position server-side and the -// offset is ignored (items still applies). The SDK sends both parameters -// as-is and does not second-guess the server. +// listing in offset_mode (WithOffsetMode) and the pools listing with a +// non-default sort still honour an offset; everywhere else the cursor defines +// the page position server-side and the offset is ignored (items still +// applies). The SDK sends both parameters as-is and does not second-guess the +// server. func WithOffset(offset uint64) ListOption { return func(p *listParams) error { p.offset = offset diff --git a/indexer/transaction.go b/indexer/transaction.go index 72ff72e..e3cfe54 100644 --- a/indexer/transaction.go +++ b/indexer/transaction.go @@ -24,8 +24,10 @@ func (c *Client) ListTransactions(ctx context.Context, opts PageOpts) ([]Transac // transactionsQuery renders the listing query for the global transaction path. // offset_mode selects the offset-based listing (no cursors); a cursor combined -// with offset_mode is rejected by the server with 400 "Bad request", which the -// SDK propagates when an explicit cursor is requested alongside it. +// with offset_mode is rejected client-side by fetchTransactions. Without +// offset_mode the request takes the cursor path (cursor param present, empty +// when unset), so a WithOffset position is silently overridden server-side — +// documented on ListTransactionsPage. func transactionsQuery(p *listParams) url.Values { forceCursor := p.cursor != "" || p.offsetMode == "" q := p.listQuery(forceCursor) @@ -56,8 +58,11 @@ func (c *Client) fetchTransactions(ctx context.Context, p *listParams) ([]Transa // Combining WithCursor with WithOffsetMode is rejected client-side with a // *RequestError. WithOffsetMode (legacy or absolute) selects the // offset-based listing instead, which has no cursors — such a page arrives as -// a bare array and is returned with a nil NextCursor. Per-block transaction -// listings remain offset-based (see the block endpoints). +// a bare array and is returned with a nil NextCursor. In the default (cursor) +// mode the cursor parameter is always present (empty on a first page), and the +// server silently overrides the WithOffset page position with the cursor — use +// WithOffsetMode for offset-based pages. Per-block transaction listings remain +// offset-based (see the block endpoints). // // Page stability is only guaranteed once the indexer's scanner is fully caught // up; a walk during catch-up or a reorg may skip or repeat an entry. From 16dcd42f70d78aefc496473acc8a14e7e7dfd636 Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Mon, 5 Oct 2026 15:42:55 +0200 Subject: [PATCH 4/8] fix(indexer): defensive-copy the cursor returned by Pager.NextCursor A caller mutating the pointed-to string previously corrupted the pager's resume position; the value is now copied before it leaves the pager. --- indexer/paginate.go | 9 ++++++--- indexer/pagination_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/indexer/paginate.go b/indexer/paginate.go index bf3f219..0ca621b 100644 --- a/indexer/paginate.go +++ b/indexer/paginate.go @@ -168,12 +168,15 @@ func (p *Pager[T]) Walk(ctx context.Context, fn func(item T) bool) error { // from this cursor in a fresh pager skips them, so only persist it once the // current page has been consumed fully — or keep using the same pager, which // serves the remainder first. The returned string is the same value the server -// delivered; it is never constructed or modified client-side. +// delivered; it is never constructed or modified client-side. The value is +// defensively copied, so mutating it does not affect the pager's resume +// position. func (p *Pager[T]) NextCursor() *string { - if !p.started { + if !p.started || p.cursor == nil { return nil } - return p.cursor + v := *p.cursor + return &v } // Truncated reports whether the last fetched page was a truncated order-book diff --git a/indexer/pagination_test.go b/indexer/pagination_test.go index b037118..1c2f0aa 100644 --- a/indexer/pagination_test.go +++ b/indexer/pagination_test.go @@ -991,3 +991,31 @@ func TestBareArrayLeadingWhitespace(t *testing.T) { t.Fatalf("got %d pools, want 1", len(pools)) } } + +// TestPagerNextCursorDefensiveCopy pins that mutating the string behind the +// pointer returned by NextCursor does not corrupt the pager's resume position. +func TestPagerNextCursorDefensiveCopy(t *testing.T) { + srv, reqs := routedServer(t, []route{ + {cursor: "", file: "holders_page1.json"}, + {cursor: "holders-cursor-2", file: "holders_page2.json"}, + }) + c := indexer.New(srv.URL) + ctx := context.Background() + + pager := indexer.CoinHoldersPager(c) + if _, err := pager.NextPage(ctx); err != nil { + t.Fatalf("NextPage: %v", err) + } + cur := pager.NextCursor() + if cur == nil || *cur != "holders-cursor-2" { + t.Fatalf("NextCursor = %v, want holders-cursor-2", cur) + } + *cur = "corrupted-by-caller" + + if _, err := pager.NextPage(ctx); err != nil { + t.Fatalf("NextPage after mutation: %v", err) + } + if len(*reqs) != 2 { + t.Fatalf("made %d requests, want 2", len(*reqs)) + } +} From d3068068356dec3b092bbb27c9e46caf19a7b90c Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Mon, 5 Oct 2026 20:02:44 +0200 Subject: [PATCH 5/8] fix(indexer): full sentinel coverage and page-aliasing docs - 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 --- CHANGELOG.md | 2 +- README.md | 4 ++-- docs/indexer.md | 12 ++++++------ indexer/errors.go | 26 +++++++++++++++++++++++++- indexer/paginate.go | 5 ++++- indexer/pagination_test.go | 10 +++++----- 6 files changed, 43 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a71b353..e93a524 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,7 @@ Cursor pagination for the api-server v2 endpoints introduced in mintlayer-core P - **Order book**: `GetOrderBook`, `OrderBookPager`, and the `OrderBookPage`/`OrderBookLevel`/`OrderBookPrice` types — aggregated price levels (exact reduced-rational price plus floored decimal rendering) per side; required `WithSide`, side-specific cursors, and the truncation invariant (`Truncated` true ⇒ `NextCursor` nil at the server's `OrderBookMaxOrders` = 10000-order cap; the paginator stops there). - **Shared paginator**: generic `Pager[T]` (`NewPager`, `NextPage`, `Walk`, `NextCursor`, `Truncated`) and the `CursorPage[T]` `{items, next_cursor}` envelope; pre-1.4.1 bare-array responses are accepted and wrapped, a server-issued empty cursor is treated as end-of-listing, and `NextPage` never loses the unserved remainder of a partially consumed page. - **List options**: `WithItems` (validated 1..=100 = `MaxNumItems`; the server rejects `items=0` on every paginated endpoint), `WithOffset`, `WithCursor` (opaque, non-empty; never constructed client-side), `WithSort`, `WithOffsetMode`, `WithSide`; inapplicable options fail client-side with `*RequestError` — the single-page methods at call time, the cursor pagers at construction time (`PoolsPager` rejects non-default sorts, `TransactionsPager` rejects `WithOffsetMode`). -- **Typed errors**: `HTTPError.Message`/`HTTPError.Kind` populated from the server's `{"error": ...}` body, `ErrorKind` classification, and the `ErrInvalidCursor` / `ErrInvalidNumItems` / `ErrTokenNotFound` sentinels matched via `errors.Is`. +- **Typed errors**: `HTTPError.Message`/`HTTPError.Kind` populated from the server's `{"error": ...}` body, `ErrorKind` classification, and an `errors.Is` sentinel for every well-known server error (`ErrInvalidCursor`, `ErrInvalidNumItems`, `ErrTokenNotFound`, `ErrBadRequest`, `ErrInvalidOffsetMode`, `ErrInvalidPoolsSortOrder`, `ErrInvalidTokenID`, `ErrInvalidOrderPair`). Documented semantics: page stability is only guaranteed once the indexer's scanner is fully caught up (walks during catch-up/reorgs may skip or repeat entries); offset-based listings remain unchanged as the simple alternative for shallow listings; on pools/holders/order book a cursor silently overrides the offset page position server-side. diff --git a/README.md b/README.md index e0e77fa..aefc492 100644 --- a/README.md +++ b/README.md @@ -185,7 +185,7 @@ order, err := c.GetOrder(ctx, "order1...") stats, err := c.GetCoinStatistics(ctx) ``` -Non-2xx responses are returned as `*indexer.HTTPError` with a `StatusCode` field. The well-known api-server v2 errors are classified in `Kind` and match the `ErrInvalidCursor`, `ErrInvalidNumItems`, and `ErrTokenNotFound` sentinels via `errors.Is`; invalid option values fail client-side with `*indexer.RequestError`. +Non-2xx responses are returned as `*indexer.HTTPError` with a `StatusCode` field. Every well-known api-server v2 error is classified in `Kind` and has a sentinel (e.g. `ErrInvalidCursor`, `ErrInvalidNumItems`, `ErrTokenNotFound`) matched via `errors.Is`; invalid option values fail client-side with `*indexer.RequestError`. --- @@ -378,6 +378,6 @@ Pass the network constant to any function that derives addresses or encodes tran - `node.RPCError` — JSON-RPC error from the node daemon (`Code`, `Message`) - `wallet.RPCError` — JSON-RPC error from the wallet daemon -- `indexer.HTTPError` — non-2xx HTTP response from the indexer (`StatusCode`, `Body`, `Message`, `Kind`); `errors.Is` matches the `indexer.ErrInvalidCursor` / `indexer.ErrInvalidNumItems` / `indexer.ErrTokenNotFound` sentinels +- `indexer.HTTPError` — non-2xx HTTP response from the indexer (`StatusCode`, `Body`, `Message`, `Kind`); every `Kind` other than `ErrorKindOther` has an `errors.Is` sentinel (e.g. `indexer.ErrInvalidCursor`, `indexer.ErrInvalidNumItems`, `indexer.ErrTokenNotFound`) - `indexer.RequestError` — request rejected client-side before it was sent (invalid `WithItems`/`WithCursor`/`WithSide`/… value or combination), with `Option` and `Reason` - WASM errors are plain `error` values with a descriptive message prefixed by `mintlayer:`. diff --git a/docs/indexer.md b/docs/indexer.md index 63c2a5a..a1d0c62 100644 --- a/docs/indexer.md +++ b/docs/indexer.md @@ -652,18 +652,18 @@ type HTTPError struct { } ``` -The api-server's well-known error messages map to `ErrorKind` values, three of which have sentinel errors for `errors.Is`. Matching is gated on the documented status code (400 for the client errors, 404 for the token lookup): the same message arriving under an unexpected status degrades to `ErrorKindOther` and does not match the sentinel. +Every well-known api-server error message maps to an `ErrorKind` value with a matching sentinel for `errors.Is` (`ErrorKindOther` is the only kind without one). Matching is gated on the documented status code (400 for the client errors, 404 for the token lookup): the same message arriving under an unexpected status degrades to `ErrorKindOther` and does not match the sentinel. | Server response | Kind | Sentinel | |---|---|---| | 400 `Invalid cursor` | `ErrorKindInvalidCursor` | `indexer.ErrInvalidCursor` | | 400 `Invalid number of items` | `ErrorKindInvalidNumItems` | `indexer.ErrInvalidNumItems` | | 404 `Token not found` | `ErrorKindTokenNotFound` | `indexer.ErrTokenNotFound` | -| 400 `Bad request` | `ErrorKindBadRequest` | — | -| 400 `Invalid offset mode` | `ErrorKindInvalidOffsetMode` | — | -| 400 `Invalid pools sort order` | `ErrorKindInvalidPoolsSortOrder` | — | -| 400 `Invalid token Id` | `ErrorKindInvalidTokenID` | — | -| 400 `Invalid order trading pair` | `ErrorKindInvalidOrderPair` | — | +| 400 `Bad request` | `ErrorKindBadRequest` | `indexer.ErrBadRequest` | +| 400 `Invalid offset mode` | `ErrorKindInvalidOffsetMode` | `indexer.ErrInvalidOffsetMode` | +| 400 `Invalid pools sort order` | `ErrorKindInvalidPoolsSortOrder` | `indexer.ErrInvalidPoolsSortOrder` | +| 400 `Invalid token Id` | `ErrorKindInvalidTokenID` | `indexer.ErrInvalidTokenID` | +| 400 `Invalid order trading pair` | `ErrorKindInvalidOrderPair` | `indexer.ErrInvalidOrderPair` | ```go holders, err := c.ListTokenHolders(ctx, tokenID) diff --git a/indexer/errors.go b/indexer/errors.go index 25a23ac..296f452 100644 --- a/indexer/errors.go +++ b/indexer/errors.go @@ -51,10 +51,23 @@ const ( // // if errors.Is(err, indexer.ErrInvalidCursor) { ... } var ( + // ErrBadRequest matches 400 "Bad request" responses (e.g. an invalid + // option combination the SDK forwards, such as a pools cursor with a + // non-default sort). + ErrBadRequest = errors.New("bad request") // ErrInvalidCursor matches 400 "Invalid cursor" responses. ErrInvalidCursor = errors.New("invalid cursor") // ErrInvalidNumItems matches 400 "Invalid number of items" responses. ErrInvalidNumItems = errors.New("invalid number of items") + // ErrInvalidOffsetMode matches 400 "Invalid offset mode" responses. + ErrInvalidOffsetMode = errors.New("invalid offset mode") + // ErrInvalidPoolsSortOrder matches 400 "Invalid pools sort order" + // responses. + ErrInvalidPoolsSortOrder = errors.New("invalid pools sort order") + // ErrInvalidTokenID matches 400 "Invalid token Id" responses. + ErrInvalidTokenID = errors.New("invalid token id") + // ErrInvalidOrderPair matches 400 "Invalid order trading pair" responses. + ErrInvalidOrderPair = errors.New("invalid order trading pair") // ErrTokenNotFound matches 404 "Token not found" responses. ErrTokenNotFound = errors.New("token not found") ) @@ -71,17 +84,28 @@ func (e *RequestError) Error() string { } // Is maps an *HTTPError onto the sentinel errors declared in this package, so -// that errors.Is works across the typed error surface: +// that errors.Is works across the typed error surface — every ErrorKind other +// than ErrorKindOther has a matching sentinel: // // errors.Is(err, indexer.ErrInvalidCursor) // errors.Is(err, indexer.ErrInvalidNumItems) // errors.Is(err, indexer.ErrTokenNotFound) func (e *HTTPError) Is(target error) bool { switch target { + case ErrBadRequest: + return e.Kind == ErrorKindBadRequest case ErrInvalidCursor: return e.Kind == ErrorKindInvalidCursor case ErrInvalidNumItems: return e.Kind == ErrorKindInvalidNumItems + case ErrInvalidOffsetMode: + return e.Kind == ErrorKindInvalidOffsetMode + case ErrInvalidPoolsSortOrder: + return e.Kind == ErrorKindInvalidPoolsSortOrder + case ErrInvalidTokenID: + return e.Kind == ErrorKindInvalidTokenID + case ErrInvalidOrderPair: + return e.Kind == ErrorKindInvalidOrderPair case ErrTokenNotFound: return e.Kind == ErrorKindTokenNotFound } diff --git a/indexer/paginate.go b/indexer/paginate.go index 0ca621b..aecb4b3 100644 --- a/indexer/paginate.go +++ b/indexer/paginate.go @@ -88,7 +88,10 @@ func fail[T any](err error) *Pager[T] { // retryable: a failed NextPage does not advance the walk. A fetched page is // normalized to a non-nil slice, so page == nil uniquely means the walk is // finished. Walk and NextPage share one consumption position, so they can be -// mixed on the same pager without duplicating or dropping items. +// mixed on the same pager without duplicating or dropping items. The returned +// slice aliases the pager's internal page buffer (Walk iterates over the same +// backing array); callers must not modify it, and should treat it as valid +// only until the next call on the pager. func (p *Pager[T]) NextPage(ctx context.Context) ([]T, error) { if p.failed != nil { return nil, p.failed diff --git a/indexer/pagination_test.go b/indexer/pagination_test.go index 1c2f0aa..67a06c2 100644 --- a/indexer/pagination_test.go +++ b/indexer/pagination_test.go @@ -554,11 +554,11 @@ func TestTypedErrorShapes(t *testing.T) { }{ {"invalid cursor", 400, `{"error":"Invalid cursor"}`, indexer.ErrorKindInvalidCursor, indexer.ErrInvalidCursor}, {"invalid num items", 400, `{"error":"Invalid number of items"}`, indexer.ErrorKindInvalidNumItems, indexer.ErrInvalidNumItems}, - {"bad request", 400, `{"error":"Bad request"}`, indexer.ErrorKindBadRequest, nil}, - {"invalid offset mode", 400, `{"error":"Invalid offset mode"}`, indexer.ErrorKindInvalidOffsetMode, nil}, - {"invalid pools sort", 400, `{"error":"Invalid pools sort order"}`, indexer.ErrorKindInvalidPoolsSortOrder, nil}, - {"invalid token id", 400, `{"error":"Invalid token Id"}`, indexer.ErrorKindInvalidTokenID, nil}, - {"invalid order pair", 400, `{"error":"Invalid order trading pair"}`, indexer.ErrorKindInvalidOrderPair, nil}, + {"bad request", 400, `{"error":"Bad request"}`, indexer.ErrorKindBadRequest, indexer.ErrBadRequest}, + {"invalid offset mode", 400, `{"error":"Invalid offset mode"}`, indexer.ErrorKindInvalidOffsetMode, indexer.ErrInvalidOffsetMode}, + {"invalid pools sort", 400, `{"error":"Invalid pools sort order"}`, indexer.ErrorKindInvalidPoolsSortOrder, indexer.ErrInvalidPoolsSortOrder}, + {"invalid token id", 400, `{"error":"Invalid token Id"}`, indexer.ErrorKindInvalidTokenID, indexer.ErrInvalidTokenID}, + {"invalid order pair", 400, `{"error":"Invalid order trading pair"}`, indexer.ErrorKindInvalidOrderPair, indexer.ErrInvalidOrderPair}, {"token not found", 404, `{"error":"Token not found"}`, indexer.ErrorKindTokenNotFound, indexer.ErrTokenNotFound}, {"unrecognised body", 500, `{"error":"boom"}`, indexer.ErrorKindOther, nil}, } From 39702a615d1b8bc1c6450ad16f2f84503c6e2443 Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Mon, 5 Oct 2026 20:13:38 +0200 Subject: [PATCH 6/8] docs(examples): correct pools-walk rejection-path comment PoolsPager rejects non-default sorts client-side with a RequestError; the server 400 only applies to ListPoolsPage with an explicit cursor. --- examples/indexer-paging/main.go | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/examples/indexer-paging/main.go b/examples/indexer-paging/main.go index 1c15aa0..9804645 100644 --- a/examples/indexer-paging/main.go +++ b/examples/indexer-paging/main.go @@ -150,8 +150,10 @@ func listTransactionsWithOffsetMode(ctx context.Context, c *indexer.Client) { } // walkPools walks the pools listing. Only the default creation-height sort -// supports cursors: WithSort(indexer.SortByPledge) plus a cursor is rejected -// by the server with 400 Bad request. +// supports cursors: PoolsPager rejects any other sort client-side with a +// *RequestError before sending anything, and the server answers 400 Bad +// request when ListPoolsPage combines an explicit cursor with a non-default +// sort. func walkPools(ctx context.Context, c *indexer.Client, items uint32) { fmt.Println("== Pools (cursor walk, by_height) ==") @@ -165,6 +167,11 @@ func walkPools(ctx context.Context, c *indexer.Client, items uint32) { return true }) if err != nil { + var reqErr *indexer.RequestError + if errors.As(err, &reqErr) { + log.Printf("pools walk rejected client-side (cursor walks need the default by_height sort): %v", err) + return + } var httpErr *indexer.HTTPError if errors.As(err, &httpErr) && httpErr.Kind == indexer.ErrorKindBadRequest { log.Printf("pools walk rejected by the server (sort+cursor combinations are invalid): %v", err) From 874710bdac5f32684758059188be1755171a3676 Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Mon, 5 Oct 2026 20:31:16 +0200 Subject: [PATCH 7/8] docs(indexer): clarify pools sort/cursor validation split; fail-fast 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 --- CHANGELOG.md | 2 +- docs/indexer.md | 4 +- examples/indexer-paging/main.go | 84 ++++++++++++++++++--------------- indexer/pool.go | 6 ++- 4 files changed, 54 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e93a524..dc6eced 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm Cursor pagination for the api-server v2 endpoints introduced in mintlayer-core PR #2130 (api-server 1.4.1): -- **Pools**: `ListPoolsPage` and `PoolsPager` — cursor envelope over `/v2/pool`; cursors work only with the default `by_height` sort (any other sort with a cursor is a server 400 `Bad request`). +- **Pools**: `ListPoolsPage` and `PoolsPager` — cursor envelope over `/v2/pool`; cursors work only with the default `by_height` sort. On `ListPoolsPage` any other sort with a cursor is a server 400 `Bad request`, propagated as `*HTTPError` (api-server contract); `PoolsPager` rejects the same combination client-side with a `*RequestError`. - **Transactions**: `ListTransactionsPage` and `TransactionsPager` — cursor envelope over the global listing (newest block first, transactions in block order within each block); `WithOffsetMode("legacy"|"absolute")` selects the offset-based listing instead, and cursor + `offset_mode` is rejected client-side with a `*RequestError`. `Transaction.BlockID` is documented as the confirming block's hash (empty for mempool transactions). - **Holders**: `ListCoinHolders`, `ListTokenHolders`, `CoinHoldersPager`, `TokenHoldersPager`, and the `Holder` type (address + amount; token ids are exact case-sensitive bech32, unknown ids are 404 `Token not found`). - **Order book**: `GetOrderBook`, `OrderBookPager`, and the `OrderBookPage`/`OrderBookLevel`/`OrderBookPrice` types — aggregated price levels (exact reduced-rational price plus floored decimal rendering) per side; required `WithSide`, side-specific cursors, and the truncation invariant (`Truncated` true ⇒ `NextCursor` nil at the server's `OrderBookMaxOrders` = 10000-order cap; the paginator stops there). diff --git a/docs/indexer.md b/docs/indexer.md index a1d0c62..bae6331 100644 --- a/docs/indexer.md +++ b/docs/indexer.md @@ -67,7 +67,7 @@ Options: | `WithItems(n)` | all | page size, accepted range 1..100 (`MaxNumItems`). The server rejects `items=0` with 400 `Invalid number of items` on every paginated endpoint, offset-based included; the SDK validates client-side. | | `WithCursor(cur)` | all cursor endpoints | resume from a server-issued cursor. Cursors are opaque: never construct, decode, or modify one — a fabricated or cross-endpoint cursor is rejected with 400 `Invalid cursor`. | | `WithOffset(n)` | pools, transactions | offset of an offset-based listing. On the holders/order-book endpoints the cursor defines the page position server-side and the offset is ignored (`items` still applies); the SDK sends both parameters as-is. | -| `WithSort(s)` | pools | `"by_height"` (default) or `"by_pledge"`; any other value fails client-side. Only the default creation-height sort supports cursors: any other sort value combined with a cursor is rejected by the server with 400 `Bad request`. | +| `WithSort(s)` | pools | `"by_height"` (default) or `"by_pledge"`; any other value fails client-side. Only the default creation-height sort supports cursors: on `ListPoolsPage` any other sort combined with a cursor is rejected by the server with 400 `Bad request` and propagated as `*HTTPError`; `PoolsPager` rejects the same combination client-side with a `*RequestError`. | | `WithOffsetMode(m)` | transactions | `"legacy"` (default) or `"absolute"`; selects the offset-based listing, which has no cursors. Combining a cursor with `offset_mode` is rejected client-side with a `*RequestError`. | | `WithSide(s)` | order book | `"ask"` or `"bid"` — required for the book. Cursors are side-specific (`book-ask` / `book-bid`): an ask cursor on a bid walk returns 400 `Invalid cursor`. | @@ -334,7 +334,7 @@ type PoolListOpts struct { func (c *Client) ListPoolsPage(ctx context.Context, opts ...ListOption) (*CursorPage[Pool], error) ``` -One page of staking pools as a cursor envelope (newest creation height first by default); pass `NextCursor` to `WithCursor` (or use `PoolsPager`) to continue the walk. The default creation-height sort is the only cursor-compatible one: `WithSort("by_pledge")` together with a cursor is rejected by the server with 400 `Bad request`, and without a cursor a non-default sort takes the offset path (no cursors, single page). +One page of staking pools as a cursor envelope (newest creation height first by default); pass `NextCursor` to `WithCursor` (or use `PoolsPager`) to continue the walk. The default creation-height sort is the only cursor-compatible one: `WithSort("by_pledge")` together with a cursor is rejected by the server with 400 `Bad request` and propagated as `*HTTPError` (only `PoolsPager` validates it client-side), and without a cursor a non-default sort takes the offset path (no cursors, single page). ### `PoolsPager` diff --git a/examples/indexer-paging/main.go b/examples/indexer-paging/main.go index 9804645..7fa052b 100644 --- a/examples/indexer-paging/main.go +++ b/examples/indexer-paging/main.go @@ -28,6 +28,7 @@ import ( "flag" "fmt" "log" + "os" "time" "github.com/mintlayer/go-sdk/indexer" @@ -56,32 +57,43 @@ func main() { return context.WithTimeout(ctx, 30*time.Second) } - wc, cancelWalk := walkCtx() - walkCoinHolders(wc, c, uint32(*items)) - cancelWalk() - - tc, cancelWalk := walkCtx() - walkTransactionsWithCursor(tc, c, uint32(*items)) - cancelWalk() - - oc, cancelWalk := walkCtx() - listTransactionsWithOffsetMode(oc, c) - cancelWalk() - - pc, cancelWalk := walkCtx() - walkPools(pc, c, uint32(*items)) - cancelWalk() + // Every walk reports its failure to main so a non-zero exit marks the + // run as failed (usable as a smoke check in CI). + failed := false + run := func(name string, walk func(context.Context) error) { + wctx, cancelWalk := walkCtx() + defer cancelWalk() + if err := walk(wctx); err != nil { + log.Printf("%s: %v", name, err) + failed = true + } + } + run("coin holders walk", func(ctx context.Context) error { + return walkCoinHolders(ctx, c, uint32(*items)) + }) + run("transactions walk", func(ctx context.Context) error { + return walkTransactionsWithCursor(ctx, c, uint32(*items)) + }) + run("offset listing", func(ctx context.Context) error { + return listTransactionsWithOffsetMode(ctx, c) + }) + run("pools walk", func(ctx context.Context) error { + return walkPools(ctx, c, uint32(*items)) + }) if *pair != "" { - bc, cancelWalk := walkCtx() - walkOrderBook(bc, c, *pair, uint32(*items)) - cancelWalk() + run("order book walk", func(ctx context.Context) error { + return walkOrderBook(ctx, c, *pair, uint32(*items)) + }) + } + if failed { + os.Exit(1) } } // walkCoinHolders pages through the native coin holders, largest balance // first, until the server runs out of cursors. -func walkCoinHolders(ctx context.Context, c *indexer.Client, items uint32) { +func walkCoinHolders(ctx context.Context, c *indexer.Client, items uint32) error { fmt.Println("== Coin holders (cursor walk) ==") pager := indexer.CoinHoldersPager(c, indexer.WithItems(items)) @@ -94,16 +106,16 @@ func walkCoinHolders(ctx context.Context, c *indexer.Client, items uint32) { return true }) if err != nil { - log.Printf("coin holders walk: %v", err) - return + return err } fmt.Printf(" ... %d holders in total\n\n", n) + return nil } // walkTransactionsWithCursor walks the global transaction listing (newest // block first, transactions in block order within each block). BlockID is the // confirming block's hash; it is empty for pending (mempool) transactions. -func walkTransactionsWithCursor(ctx context.Context, c *indexer.Client, items uint32) { +func walkTransactionsWithCursor(ctx context.Context, c *indexer.Client, items uint32) error { fmt.Println("== Global transactions (cursor walk) ==") pager := indexer.TransactionsPager(c, indexer.WithItems(items)) @@ -111,8 +123,7 @@ func walkTransactionsWithCursor(ctx context.Context, c *indexer.Client, items ui for { page, err := pager.NextPage(ctx) if err != nil { - log.Printf("transactions walk: %v", err) - return + return err } if page == nil { break // nil NextCursor from the server: listing exhausted @@ -124,12 +135,13 @@ func walkTransactionsWithCursor(ctx context.Context, c *indexer.Client, items ui } } fmt.Printf(" done; %d pending transactions seen\n\n", pending) + return nil } // listTransactionsWithOffsetMode uses the offset-based listing instead of a // cursor walk. offset_mode=absolute treats the offset as an absolute // tx_global_index boundary; the two styles cannot be combined (server 400). -func listTransactionsWithOffsetMode(ctx context.Context, c *indexer.Client) { +func listTransactionsWithOffsetMode(ctx context.Context, c *indexer.Client) error { fmt.Println("== Global transactions (offset_mode=absolute) ==") page, err := c.ListTransactionsPage(ctx, @@ -138,8 +150,7 @@ func listTransactionsWithOffsetMode(ctx context.Context, c *indexer.Client) { indexer.WithItems(5), ) if err != nil { - log.Printf("offset listing: %v", err) - return + return err } // Offset-mode pages have no cursors: NextCursor is always nil here. for _, tx := range page.Items { @@ -147,6 +158,7 @@ func listTransactionsWithOffsetMode(ctx context.Context, c *indexer.Client) { } fmt.Printf(" (%d transactions; offset listings have no NextCursor: %v)\n\n", len(page.Items), page.NextCursor == nil) + return nil } // walkPools walks the pools listing. Only the default creation-height sort @@ -154,7 +166,7 @@ func listTransactionsWithOffsetMode(ctx context.Context, c *indexer.Client) { // *RequestError before sending anything, and the server answers 400 Bad // request when ListPoolsPage combines an explicit cursor with a non-default // sort. -func walkPools(ctx context.Context, c *indexer.Client, items uint32) { +func walkPools(ctx context.Context, c *indexer.Client, items uint32) error { fmt.Println("== Pools (cursor walk, by_height) ==") pager := indexer.PoolsPager(c, indexer.WithItems(items)) @@ -169,25 +181,23 @@ func walkPools(ctx context.Context, c *indexer.Client, items uint32) { if err != nil { var reqErr *indexer.RequestError if errors.As(err, &reqErr) { - log.Printf("pools walk rejected client-side (cursor walks need the default by_height sort): %v", err) - return + return fmt.Errorf("rejected client-side (cursor walks need the default by_height sort): %w", err) } var httpErr *indexer.HTTPError if errors.As(err, &httpErr) && httpErr.Kind == indexer.ErrorKindBadRequest { - log.Printf("pools walk rejected by the server (sort+cursor combinations are invalid): %v", err) - return + return fmt.Errorf("rejected by the server (sort+cursor combinations are invalid): %w", err) } - log.Printf("pools walk: %v", err) - return + return err } fmt.Printf(" ... %d pools in total\n\n", n) + return nil } // walkOrderBook walks both sides of the order book. Cursors are side-specific // (book-ask vs book-bid), and a page truncated at the server's aggregation cap // (10000 orders) carries no continuation cursor: the walk stops there even // though the book is incomplete. -func walkOrderBook(ctx context.Context, c *indexer.Client, pair string, items uint32) { +func walkOrderBook(ctx context.Context, c *indexer.Client, pair string, items uint32) error { fmt.Printf("== Order book %s ==\n", pair) for _, side := range []string{indexer.SideAsk, indexer.SideBid} { @@ -207,8 +217,7 @@ func walkOrderBook(ctx context.Context, c *indexer.Client, pair string, items ui return true }) if err != nil { - log.Printf("%s book walk: %v", side, err) - continue + return fmt.Errorf("%s book walk: %w", side, err) } if pager.Truncated() { fmt.Printf(" ... %d levels shown; BOOK TRUNCATED at the aggregation cap — no continuation cursor exists\n", n) @@ -217,6 +226,7 @@ func walkOrderBook(ctx context.Context, c *indexer.Client, pair string, items ui } } fmt.Println() + return nil } func orDash(s string) string { diff --git a/indexer/pool.go b/indexer/pool.go index e4bd71a..09d704f 100644 --- a/indexer/pool.go +++ b/indexer/pool.go @@ -58,8 +58,10 @@ func (c *Client) fetchPools(ctx context.Context, p *listParams) ([]Pool, *string // cursor — see WithSort). // // The default sort is the only cursor-compatible one: combining WithCursor -// (or the walk of a pager) with WithSort(SortByPledge) is rejected by the -// server with 400 "Bad request". +// with WithSort(SortByPledge) is rejected by the server with 400 "Bad +// request" and propagated as *HTTPError — only PoolsPager validates this +// combination client-side (with a *RequestError), matching the api-server +// contract this method mirrors. // // When WithOffset is set without a cursor, the cursor silently overrides the // offset page position server-side (items still applies); the SDK sends both From 54871b42673afd8d5c88e9f5038eaa23a2fd0a46 Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Mon, 5 Oct 2026 20:50:19 +0200 Subject: [PATCH 8/8] fix(indexer): end pager walks on an empty fetched page 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. --- CHANGELOG.md | 2 +- indexer/paginate.go | 12 +++++++++--- indexer/pagination_test.go | 14 ++++++++------ 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc6eced..6c3b316 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ Cursor pagination for the api-server v2 endpoints introduced in mintlayer-core P - **Transactions**: `ListTransactionsPage` and `TransactionsPager` — cursor envelope over the global listing (newest block first, transactions in block order within each block); `WithOffsetMode("legacy"|"absolute")` selects the offset-based listing instead, and cursor + `offset_mode` is rejected client-side with a `*RequestError`. `Transaction.BlockID` is documented as the confirming block's hash (empty for mempool transactions). - **Holders**: `ListCoinHolders`, `ListTokenHolders`, `CoinHoldersPager`, `TokenHoldersPager`, and the `Holder` type (address + amount; token ids are exact case-sensitive bech32, unknown ids are 404 `Token not found`). - **Order book**: `GetOrderBook`, `OrderBookPager`, and the `OrderBookPage`/`OrderBookLevel`/`OrderBookPrice` types — aggregated price levels (exact reduced-rational price plus floored decimal rendering) per side; required `WithSide`, side-specific cursors, and the truncation invariant (`Truncated` true ⇒ `NextCursor` nil at the server's `OrderBookMaxOrders` = 10000-order cap; the paginator stops there). -- **Shared paginator**: generic `Pager[T]` (`NewPager`, `NextPage`, `Walk`, `NextCursor`, `Truncated`) and the `CursorPage[T]` `{items, next_cursor}` envelope; pre-1.4.1 bare-array responses are accepted and wrapped, a server-issued empty cursor is treated as end-of-listing, and `NextPage` never loses the unserved remainder of a partially consumed page. +- **Shared paginator**: generic `Pager[T]` (`NewPager`, `NextPage`, `Walk`, `NextCursor`, `Truncated`) and the `CursorPage[T]` `{items, next_cursor}` envelope; pre-1.4.1 bare-array responses are accepted and wrapped, a server-issued empty cursor is treated as end-of-listing, an empty fetched page ends the walk (the api-server issues a cursor only alongside items), and `NextPage` never loses the unserved remainder of a partially consumed page. - **List options**: `WithItems` (validated 1..=100 = `MaxNumItems`; the server rejects `items=0` on every paginated endpoint), `WithOffset`, `WithCursor` (opaque, non-empty; never constructed client-side), `WithSort`, `WithOffsetMode`, `WithSide`; inapplicable options fail client-side with `*RequestError` — the single-page methods at call time, the cursor pagers at construction time (`PoolsPager` rejects non-default sorts, `TransactionsPager` rejects `WithOffsetMode`). - **Typed errors**: `HTTPError.Message`/`HTTPError.Kind` populated from the server's `{"error": ...}` body, `ErrorKind` classification, and an `errors.Is` sentinel for every well-known server error (`ErrInvalidCursor`, `ErrInvalidNumItems`, `ErrTokenNotFound`, `ErrBadRequest`, `ErrInvalidOffsetMode`, `ErrInvalidPoolsSortOrder`, `ErrInvalidTokenID`, `ErrInvalidOrderPair`). diff --git a/indexer/paginate.go b/indexer/paginate.go index aecb4b3..96b3c0b 100644 --- a/indexer/paginate.go +++ b/indexer/paginate.go @@ -87,7 +87,10 @@ func fail[T any](err error) *Pager[T] { // returns (nil, nil). Options and transport errors are returned as-is and are // retryable: a failed NextPage does not advance the walk. A fetched page is // normalized to a non-nil slice, so page == nil uniquely means the walk is -// finished. Walk and NextPage share one consumption position, so they can be +// finished. An empty fetched page also ends the walk: the api-server issues a +// cursor only alongside items, so an empty page cannot legitimately continue +// (this also bounds Walk against a non-compliant peer that never advances). +// Walk and NextPage share one consumption position, so they can be // mixed on the same pager without duplicating or dropping items. The returned // slice aliases the pager's internal page buffer (Walk iterates over the same // backing array); callers must not modify it, and should treat it as valid @@ -122,10 +125,13 @@ func (p *Pager[T]) NextPage(ctx context.Context) ([]T, error) { } p.items = items p.pos = len(items) // the page is handed out whole via the return value - if next == nil || truncated || *next == "" { + if next == nil || truncated || *next == "" || len(items) == 0 { // A server-issued empty cursor is not a continuation position (it would // re-serve the first page forever); treat it as end-of-listing, matching - // the client-side WithCursor validation. + // the client-side WithCursor validation. An empty page likewise ends the + // walk: the api-server issues a cursor only alongside items, so an empty + // page with a cursor would mean a non-compliant peer — terminating + // instead of looping unboundedly. p.done = true p.cursor = nil } else { diff --git a/indexer/pagination_test.go b/indexer/pagination_test.go index 67a06c2..a439e8a 100644 --- a/indexer/pagination_test.go +++ b/indexer/pagination_test.go @@ -919,8 +919,10 @@ func TestTokenHoldersEmptyTokenID(t *testing.T) { } // TestPagerNilItemsWithCursor pins that a fetched page with a null/empty items -// array is normalized to a non-nil slice, so page == nil uniquely means the -// walk is finished even when the server still issued a cursor. +// array is normalized to a non-nil slice (so page == nil uniquely means the +// walk is finished), and that an empty page ends the walk without a +// continuation request: the api-server issues a cursor only alongside items, +// so an empty page with a cursor would mean a non-compliant peer. func TestPagerNilItemsWithCursor(t *testing.T) { srv, reqs := routedServer(t, []route{ {cursor: "", file: "holders_page_empty_items.json"}, @@ -944,11 +946,11 @@ func TestPagerNilItemsWithCursor(t *testing.T) { if err != nil { t.Fatalf("NextPage page2: %v", err) } - if page2 == nil || len(page2) != 1 { - t.Fatalf("page2 = (%v), want 1 item", page2) + if page2 != nil { + t.Fatalf("page2 = %d items, want nil (empty page ends the walk)", len(page2)) } - if len(*reqs) != 2 { - t.Fatalf("made %d requests, want 2", len(*reqs)) + if len(*reqs) != 1 { + t.Fatalf("made %d requests, want 1 (no continuation after an empty page)", len(*reqs)) } }