Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
30 changes: 24 additions & 6 deletions supra_sdk/clients/api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
{
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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):
Expand Down
54 changes: 41 additions & 13 deletions supra_sdk/clients/rest/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -20,6 +25,7 @@
ACCOUNT_RESOURCES_ENDPOINT,
ACCOUNT_TRANSACTIONS_ENDPOINT,
AccountHistoryCategory,
AccountHistoryPage,
AutomatedTransactionsPagination,
Pagination,
PaginationWithOrder,
Expand Down Expand Up @@ -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`).

Expand All @@ -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
Expand All @@ -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`.
Expand All @@ -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,
Expand All @@ -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.
Expand All @@ -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),
)
Expand Down
21 changes: 21 additions & 0 deletions supra_sdk/clients/rest/rest_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
54 changes: 49 additions & 5 deletions tests/unit/test_paging.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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))
Expand Down
Loading