From 4fa5f353dcf1e11e4a4e3ed96494daeb430202e7 Mon Sep 17 00:00:00 2001 From: Isaac Doidge Date: Wed, 4 Nov 2026 12:27:45 +1000 Subject: [PATCH] feat(rest)!: return the history coverage headers from account_history `AccountRestClient.account_history` returns an `AccountHistoryPage` with `entries`, `cursor`, `indexed_from` and `oldest_block` instead of an `(entries, cursor)` tuple. `indexed_from` is read from `x-supra-history-indexed-from` and `oldest_block` from `x-supra-oldest-block`; each is `None` when the header is absent or not decimal digits. The header parsing of `ApiGoneError` moves into `parse_height_header`, which `account_history` also uses. `iter_account_history` still yields entries. BREAKING CHANGE: `account_history` returns `AccountHistoryPage` instead of `tuple[list[dict], str | None]`. Co-Authored-By: Claude Opus 5.5 --- README.md | 3 +- supra_sdk/clients/api_client.py | 30 ++++++++++++---- supra_sdk/clients/rest/account.py | 54 +++++++++++++++++++++------- supra_sdk/clients/rest/rest_types.py | 21 +++++++++++ tests/unit/test_paging.py | 54 +++++++++++++++++++++++++--- 5 files changed, 137 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index f217279..a6b77d8 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,8 @@ Which method lists them depends on where the account holds SUPRA: field names the fungible store, not the account, and no `0x1::coin` events. ```python -entries, cursor = await client.account_history(address, categories=["fa"]) +history = await client.account_history(address, categories=["fa"]) +print(history.entries, history.cursor, history.indexed_from, history.oldest_block) async for entry in client.iter_account_history(address, categories=["sent", "fa"]): print(entry["categories"], entry["transaction"]["hash"]) async for transaction in client.iter_account_fa_transactions(address): diff --git a/supra_sdk/clients/api_client.py b/supra_sdk/clients/api_client.py index 36ec1c1..ce0f29b 100644 --- a/supra_sdk/clients/api_client.py +++ b/supra_sdk/clients/api_client.py @@ -25,6 +25,9 @@ OLDEST_BLOCK_HEADER = "x-supra-oldest-block" """Response header in which the node reports the height of the oldest block it still stores.""" +HISTORY_INDEXED_FROM_HEADER = "x-supra-history-indexed-from" +"""Response header in which the node reports the lowest block height the account history covers.""" + # Statuses after which a GET is repeated when `ApiClientConfig.retry` is set. _RETRYABLE_STATUSES = frozenset( { @@ -407,6 +410,26 @@ class ApiRequestTimeoutError(ApiError): request. A GET can be repeated, which `RetryPolicy` does when set.""" +def parse_height_header(headers: Mapping[str, str], name: str) -> int | None: + """Reads a block height from a response header. + + Args: + headers (Mapping[str, str]): The response headers. + name (str): Name of the header. + + Returns: + int | None: The height, or `None` when the header is absent or its value, once stripped, is not decimal digits. + + """ + value = headers.get(name) + if value is None or not _DECIMAL.fullmatch(value.strip()): + return None + # ValueError: more digits than `int` converts (`sys.get_int_max_str_digits`). + with contextlib.suppress(ValueError): + return int(value) + return None + + class ApiGoneError(ApiError): """Exception raised when the API returns 410: the requested data existed, but the node has pruned it. @@ -436,12 +459,7 @@ def __init__( """ super().__init__(message, status_code, headers, body) - value = self.headers.get(OLDEST_BLOCK_HEADER) - self.oldest_block = None - if value is not None and _DECIMAL.fullmatch(value.strip()): - # ValueError: more digits than `int` converts (`sys.get_int_max_str_digits`). - with contextlib.suppress(ValueError): - self.oldest_block = int(value) + self.oldest_block = parse_height_header(self.headers, OLDEST_BLOCK_HEADER) class ApiRateLimitError(ApiError): diff --git a/supra_sdk/clients/rest/account.py b/supra_sdk/clients/rest/account.py index 67aabd7..f2dea00 100644 --- a/supra_sdk/clients/rest/account.py +++ b/supra_sdk/clients/rest/account.py @@ -6,6 +6,11 @@ from typing import Any from supra_sdk.account_address import AccountAddress +from supra_sdk.clients.api_client import ( + HISTORY_INDEXED_FROM_HEADER, + OLDEST_BLOCK_HEADER, + parse_height_header, +) from supra_sdk.clients.rest.paging import next_cursor, paginate from supra_sdk.clients.rest.rest_client import RestClient from supra_sdk.clients.rest.rest_types import ( @@ -20,6 +25,7 @@ ACCOUNT_RESOURCES_ENDPOINT, ACCOUNT_TRANSACTIONS_ENDPOINT, AccountHistoryCategory, + AccountHistoryPage, AutomatedTransactionsPagination, Pagination, PaginationWithOrder, @@ -527,7 +533,7 @@ async def account_history( account_address: AccountAddress | str, pagination: PaginationWithOrder | None = None, categories: Iterable[AccountHistoryCategory | str] | None = None, - ) -> tuple[list[dict[str, Any]], str | None]: + ) -> AccountHistoryPage: """Retrieves the history of an account: every finalized transaction that concerns the account, once each, with the categories it matched for the account (`GET /rpc/v3/accounts/{address}/history`). @@ -539,10 +545,13 @@ async def account_history( `ApiError`) with `status_code` 404 and the message `Not Found`, the HTTP reason phrase. Automated transactions and automation registry records are not listed; use - `account_automated_transactions` for those. While the node sends the `x-supra-history-indexed-from` response - header, the history does not cover the blocks below the height it names: the node is indexing, or has not - indexed, the blocks it held before it kept the history. This method does not return that header, so a caller - cannot tell from its result whether the history covers the older blocks. + `account_automated_transactions` for those. + + Coverage: `indexed_from` is the lowest block height the history covers. The node sends it while blocks below + that height remain unindexed, whether or not its `account_history_backfill` setting has it indexing them; + the transactions of those blocks are not listed. It is `None` when the history covers every block the node + holds. `oldest_block` is the height of the oldest block the node holds, and is `None` until the node's + archive has indexed at least one block. Paging: the node applies `categories` while it scans the account's history and reads at most 8 rows for each entry requested, so a page can hold fewer entries than `count`, or none, before the history is @@ -563,12 +572,18 @@ async def account_history( category is accepted too. Default to None, which the node reads as all four. Returns: - tuple[list[dict[str, Any]], str | None]: A tuple containing, - - list[dict[str, Any]]: The entries, each `{"categories": [...], "transaction": {...}}`, where - `categories` lists the categories the transaction matched for the account, in the order `sent`, - `coin`, `fa`, `da`, and `transaction` is a `TransactionV3`, tagged by `txn_type`, as + AccountHistoryPage: A page containing, + - entries (list[dict[str, Any]]): The entries, each `{"categories": [...], "transaction": {...}}`, + where `categories` lists the categories the transaction matched for the account, in the order + `sent`, `coin`, `fa`, `da`, and `transaction` is a `TransactionV3`, tagged by `txn_type`, as `account_coin_transactions` returns it. - - str | None: Cursor to retrieve the next page, or `None` when the history has ended. + - cursor (str | None): Cursor to retrieve the next page, or `None` when the history has ended. + - indexed_from (int | None): The lowest block height the history covers, from the + `x-supra-history-indexed-from` header, or `None` when the node sends none or the value is not a + decimal integer. + - oldest_block (int | None): Height of the oldest block the node holds, from the + `x-supra-oldest-block` header, or `None` when the node sends none or the value is not a decimal + integer. Raises: ValueError: If `categories` is empty or names something other than `sent`, `coin`, `fa` or `da`. @@ -583,7 +598,14 @@ async def account_history( response = await self.api_client.get(endpoint=endpoint, params=params) page = response.json() # The route exists only on v12 nodes, which end a feed by omitting the header; `"0"` is a cursor here. - return page, next_cursor(response, page, only_missing_header_ends=True) + return AccountHistoryPage( + entries=page, + cursor=next_cursor(response, page, only_missing_header_ends=True), + indexed_from=parse_height_header( + response.headers, HISTORY_INDEXED_FROM_HEADER + ), + oldest_block=parse_height_header(response.headers, OLDEST_BLOCK_HEADER), + ) def iter_account_history( self, @@ -595,7 +617,8 @@ def iter_account_history( The iteration ends when a response has no `x-supra-cursor` header, or when the node repeats a cursor. An empty page with a cursor and the cursor `"0"` do not end it. See `account_history` for the node release it - needs and what the history covers. + needs and what the history covers. The iterator does not report the coverage headers; `account_history` + returns them with each page. Args: account_address (AccountAddress | str): Address of the account, as an `AccountAddress` or a hex string. @@ -611,8 +634,13 @@ def iter_account_history( """ if categories is not None and not isinstance(categories, str): categories = tuple(categories) + + async def fetch(p: PaginationWithOrder) -> tuple[list[Any], str | None]: + page = await self.account_history(account_address, p, categories) + return page.entries, page.cursor + return paginate( - lambda p: self.account_history(account_address, p, categories), + fetch, pagination or PaginationWithOrder(), lambda p, cursor: replace(p, start=cursor), ) diff --git a/supra_sdk/clients/rest/rest_types.py b/supra_sdk/clients/rest/rest_types.py index 07b0ad9..1d5540b 100644 --- a/supra_sdk/clients/rest/rest_types.py +++ b/supra_sdk/clients/rest/rest_types.py @@ -357,6 +357,27 @@ class AccountHistoryCategory(str, Enum): DA = "da" +@dataclass(frozen=True) +class AccountHistoryPage: + """One page of an account's history, as returned by `AccountRestClient.account_history`. + + Attributes: + entries (list[dict[str, Any]]): The entries, each `{"categories": [...], "transaction": {...}}`. + cursor (str | None): Cursor to retrieve the next page, or `None` when the history has ended. + indexed_from (int | None): The lowest block height the account history covers, from the + `x-supra-history-indexed-from` response header. Present while blocks below it remain unindexed; the + transactions of those blocks are not listed. `None` when the header is absent or not a decimal integer. + oldest_block (int | None): Height of the oldest block the node holds, from the `x-supra-oldest-block` + response header. `None` when the header is absent or not a decimal integer. + + """ + + entries: list[dict[str, Any]] + cursor: str | None + indexed_from: int | None + oldest_block: int | None + + class TransactionType(str, Enum): """Enumeration of transaction types. diff --git a/tests/unit/test_paging.py b/tests/unit/test_paging.py index 8e10750..fb67691 100644 --- a/tests/unit/test_paging.py +++ b/tests/unit/test_paging.py @@ -12,6 +12,7 @@ from supra_sdk.clients.rest.paging import next_cursor from supra_sdk.clients.rest.rest_types import ( AccountHistoryCategory, + AccountHistoryPage, AutomatedTransactionsPagination, EventsPagination, Pagination, @@ -666,18 +667,61 @@ async def test_only_a_missing_header_ends_a_page(self): self.node = await FakeNode.create(lambda _: self.response) client = self.node.client self.response = page([], "0") - self.assertEqual(await client.account_history(ADDRESS), ([], "0")) + self.assertEqual(await self.history(client), ([], "0")) self.response = page([], "7") self.assertEqual( - await client.account_history(ADDRESS, PaginationWithOrder(start="7")), + await self.history(client, PaginationWithOrder(start="7")), ([], "7"), ) self.response = page([{"n": 1}], "0") - self.assertEqual(await client.account_history(ADDRESS), ([{"n": 1}], "0")) + self.assertEqual(await self.history(client), ([{"n": 1}], "0")) self.response = page([{"n": 1}], None) - self.assertEqual(await client.account_history(ADDRESS), ([{"n": 1}], None)) + self.assertEqual(await self.history(client), ([{"n": 1}], None)) self.response = page([], "") - self.assertEqual(await client.account_history(ADDRESS), ([], None)) + self.assertEqual(await self.history(client), ([], None)) + + async def history( + self, client: SupraClient, pagination: PaginationWithOrder | None = None + ) -> tuple[list[dict[str, Any]], str | None]: + result = await client.account_history(ADDRESS, pagination) + return result.entries, result.cursor + + async def test_returns_the_coverage_headers(self): + headers = { + CURSOR: "5", + "x-supra-history-indexed-from": "120", + "x-supra-oldest-block": "7", + } + self.node = await FakeNode.create( + lambda _: httpx.Response(200, json=[{"n": 1}], headers=headers) + ) + result = await self.node.client.account_history(ADDRESS) + self.assertEqual( + result, + AccountHistoryPage( + entries=[{"n": 1}], cursor="5", indexed_from=120, oldest_block=7 + ), + ) + + async def test_absent_coverage_headers_are_none(self): + self.node = await FakeNode.create(lambda _: page([], None)) + result = await self.node.client.account_history(ADDRESS) + self.assertIsNone(result.indexed_from) + self.assertIsNone(result.oldest_block) + + async def test_a_non_decimal_coverage_header_is_none(self): + for value in ("abc", "-1", "1.5", "", "0x10"): + headers = { + "x-supra-history-indexed-from": value, + "x-supra-oldest-block": value, + } + self.node = await FakeNode.create( + lambda _, headers=headers: httpx.Response(200, json=[], headers=headers) + ) + result = await self.node.client.account_history(ADDRESS) + self.assertIsNone(result.indexed_from, value) + self.assertIsNone(result.oldest_block, value) + await self.node.close() async def test_a_node_without_the_route_raises_not_found_error(self): self.node = await FakeNode.create(lambda _: httpx.Response(404))