diff --git a/README.md b/README.md index 7fd7d00..cc10474 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ Accounts that still hold a `CoinStore` balance keep it there until they migrate `None` at the end of the feed on v11.7.x and v12 nodes (the method reads a v11.7.x node's `"0"` on an empty page as the end). Stop when the cursor is `None`, not on an empty page or on `"0"`: a page filtered by `transaction_type` can be empty and still carry a cursor to continue from, and `"0"` can be the position of a genesis row. + `iter_account_fa_transactions` takes the same arguments and follows the cursor until the feed ends. ```python balance = await client.account_fa_balance(alice.address()) @@ -52,6 +53,28 @@ takes a `coin_type`). See [`examples/fungible_asset.py`](examples/fungible_asset For the function specific documentation, check out the `supra-python-sdk` [reference documentation](https://supra-python-sdk.docs.supra.com/). +## Listing an account's SUPRA movements + +Which method lists them depends on where the account holds SUPRA: + +- Coin layout (Mainnet and Testnet accounts created before governance enables feature 64 or 65): SUPRA is in the + account's `CoinStore`, and `account_coin_transactions` lists its movements. +- Primary-store layout: SUPRA is in the account's primary fungible store, and `account_coin_transactions` lists + none of its movements. While feature 64 or 65 is enabled, registering SUPRA creates the primary store instead of a + `CoinStore`, so new accounts hold SUPRA there; so does an account that migrated its SUPRA. On a chain + created with features 64 and 65 on, such as Devnet on `supra_node_v12.0.0`, no account has a + `CoinStore`. Use `account_history` with the category `fa` (nodes from `supra_node_v12.0.0`) or + `account_fa_transactions`. SUPRA transfers emit `0x1::fungible_asset::Withdraw` and `Deposit` events, whose `store` + field names the fungible store, not the account, and no `0x1::coin` events. + +```python +entries, cursor = await client.account_history(address, categories=["fa"]) +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): + print(transaction["hash"]) +``` + ## Contributing If you found a bug or would like to request a feature, please file an [issue](https://github.com/Entropy-Foundation/supra-python-sdk/issues/new). diff --git a/supra_sdk/clients/rest/account.py b/supra_sdk/clients/rest/account.py index cc343b5..4eeb6ee 100644 --- a/supra_sdk/clients/rest/account.py +++ b/supra_sdk/clients/rest/account.py @@ -1,12 +1,10 @@ # Copyright © Supra # SPDX-License-Identifier: Apache-2.0 -from collections.abc import AsyncIterator +from collections.abc import AsyncIterator, Iterable from dataclasses import replace from typing import Any -import httpx - from supra_sdk.account_address import AccountAddress from supra_sdk.clients.rest.paging import next_cursor, paginate from supra_sdk.clients.rest.rest_client import RestClient @@ -15,11 +13,13 @@ ACCOUNT_COIN_TRANSACTIONS_ENDPOINT, ACCOUNT_ENDPOINT, ACCOUNT_FA_TRANSACTIONS_ENDPOINT, + ACCOUNT_HISTORY_ENDPOINT, ACCOUNT_MODULE_ENDPOINT, ACCOUNT_MODULES_ENDPOINT, ACCOUNT_RESOURCE_ENDPOINT, ACCOUNT_RESOURCES_ENDPOINT, ACCOUNT_TRANSACTIONS_ENDPOINT, + AccountHistoryCategory, AutomatedTransactionsPagination, Pagination, PaginationWithOrder, @@ -32,39 +32,20 @@ ) -def _next_cursor( - response: httpx.Response, - page: list[Any], - *, - sent: Any = None, - zero_continues: bool = False, -) -> str | None: - """The cursor of the next page of `fa_transactions`, or `None` when the response ends the feed. The rule is - stated at `AccountRestClient.account_fa_transactions`. - - Args: - response (httpx.Response): Response of a paged request. - page (list[Any]): The items of the page the response carried. - sent (Any): The cursor the request was sent with, or `None` when it carried none. An empty string counts as - none. Default to None. - zero_continues (bool): True when the request had no cursor, was ascending and carried a `type` filter. - Default to False. +def _type_param(transaction_type: TransactionType | str | None) -> str | None: + """The `type` query parameter for a transaction type filter, or `None` when there is no filter. - Returns: - str | None: The cursor to pass back to read the next page, or `None` when the feed has ended. + An empty `transaction_type` is not a type, so, like an empty `start`, it is not sent. """ - cursor = response.headers.get("x-supra-cursor", "") - if not cursor.strip(): + if transaction_type is None: return None - if page: - return cursor - if cursor == "0" and zero_continues: - return cursor - sent_cursor = None if sent is None or sent == "" else str(sent) - if cursor == "0" or cursor == sent_cursor: - return None - return cursor + value = str( + transaction_type.value + if isinstance(transaction_type, TransactionType) + else transaction_type + ) + return value or None class AccountRestClient(RestClient): @@ -261,39 +242,69 @@ async def account_coin_transactions( self, account_address: AccountAddress | str, pagination: PaginationWithOrder | None = None, + transaction_type: TransactionType | str | None = None, ) -> tuple[list[dict[str, Any]], str | None]: """Retrieves details of finalized coin deposit/withdraw type transactions associated with a given account. It lists movements of the account's `CoinStore` only. It does not list SUPRA held in primary fungible stores, - which is how accounts hold SUPRA on Devnet, and on Testnet and Mainnet once the features that hold SUPRA in - primary stores (64 and 65) are enabled. Use `account_fa_transactions` for those. + which is how accounts hold SUPRA on Devnet, and on Testnet and Mainnet once a feature that holds SUPRA in + primary stores (64 or 65) is enabled. Use `account_fa_transactions` for those. Args: account_address (AccountAddress | str): Address of the account, as an `AccountAddress` or a hex string. pagination (PaginationWithOrder | None): Pagination options. Default to None. + transaction_type (TransactionType | str | None): Keeps only transactions of this type (`auto`, `user`, + `meta`, `record` or, on v12 nodes only, `settlement`), as a `TransactionType` or its string value. + It is sent as the node's `type` query parameter; a v11.7.x node rejects `settlement` with HTTP 400. + The node applies it after reading a page, so a filtered page can be empty and still carry a cursor. + Default to None, which keeps every type; an empty string is not sent and also keeps every type. Returns: tuple[list[dict[str, Any]], str | None]: A tuple containing, - list[dict[str, Any]]: Details of finalized coin deposit/withdraw type transactions associated with a - given account. + given account. While feature 64 or 65 is enabled, registering SUPRA creates the account's primary + fungible store instead of a `CoinStore`, so new accounts, and accounts that migrated + their SUPRA, hold SUPRA in their primary store and the list holds none of its movements. On a + chain created with those features on, such as Devnet, no account has a `CoinStore`. + `account_history` with the category `fa` lists those movements too, on nodes from + `supra_node_v12.0.0`. - str | None: Cursor to retrieve the next page, or `None` when the feed has ended. Nodes up to - v11.7.x end this feed with the cursor `0` on an empty page; that is reported as `None` too. An - empty page carrying a cursor other than `0` and the request's own does not end the feed. + v11.7.x end this feed with the cursor `0` on an empty page; that is reported as `None` too, + except for the first request (no `start`) of an ascending read with a `transaction_type`: block 0 + has timestamp 0, so `0` can be the position of a genesis row that the filter excluded, and `0` is + returned. The node answers the request with `start=0` that follows with `0` again (v11.7.x) or + without a cursor (v12) when the feed has no further rows; both end the feed. An empty page + carrying a cursor other than `0` and the request's own does not end the feed. """ endpoint = format_endpoint( ACCOUNT_COIN_TRANSACTIONS_ENDPOINT, account_address=account_address ) params = pagination.to_params() if pagination else {} + type_param = _type_param(transaction_type) + filtered = type_param is not None + if type_param is not None: + params["type"] = type_param response = await self.api_client.get(endpoint=endpoint, params=params) page = response.json() - # The request carries no `type` filter, so `"0"` on an empty page is the end of the feed on both node lines. - return page, next_cursor(response, page, sent=params.get("start")) + # Without a `type` filter `"0"` on an empty page is the end of the feed on both node lines. With one, the + # first ascending page can be empty with `"0"` because the filter excluded a genesis row, so it continues. + sent = params.get("start") + zero_continues = ( + sent is None + and pagination is not None + and pagination.ascending is True + and filtered + ) + return page, next_cursor( + response, page, sent=sent, zero_continues=zero_continues + ) def iter_account_coin_transactions( self, account_address: AccountAddress | str, pagination: PaginationWithOrder | None = None, + transaction_type: TransactionType | str | None = None, ) -> AsyncIterator[dict[str, Any]]: """Iterates over all coin deposit/withdraw type transactions of a given account, requesting further pages until the feed ends. @@ -301,17 +312,26 @@ def iter_account_coin_transactions( The iteration ends when the node sends no next cursor or repeats one (see `supra_sdk.clients.rest.paging.next_cursor`). An empty page with a new cursor does not end it. + It does not list SUPRA held in primary fungible stores: that of every account on a chain created with + features 64 and 65 on, such as Devnet, and, while feature 64 or 65 is enabled, that of new accounts and of + accounts that migrated their SUPRA; see `account_coin_transactions`. Use `account_fa_transactions`, or + `iter_account_history` with the category `fa` on nodes from `supra_node_v12.0.0`, for those. + Args: account_address (AccountAddress | str): Address of the account, as an `AccountAddress` or a hex string. pagination (PaginationWithOrder | None): Pagination options of the first request; `count` sets the page size, `start` the cursor to begin from and `ascending` the order. Default to None. + transaction_type (TransactionType | str | None): Keeps only transactions of this type, as for + `account_coin_transactions`; `settlement` needs a v12 node. Default to None, which keeps every type. Returns: AsyncIterator[dict[str, Any]]: The transactions, in the requested order. """ return paginate( - lambda p: self.account_coin_transactions(account_address, p), + lambda p: self.account_coin_transactions( + account_address, p, transaction_type + ), pagination or PaginationWithOrder(), lambda p, cursor: replace(p, start=cursor), ) @@ -350,7 +370,7 @@ async def account_fa_transactions( - page without rows and header equal to the request's `start`: `None`. - any other page without rows: the header. - An empty `start` is not sent. + An empty `start` and an empty `transaction_type` are not sent. Args: account_address (AccountAddress | str): Address of the account that owns the store, as an @@ -387,12 +407,9 @@ async def account_fa_transactions( start = params.get("start") if start is None or start == "": params.pop("start", None) - if transaction_type is not None: - params["type"] = str( - transaction_type.value - if isinstance(transaction_type, TransactionType) - else transaction_type - ) + type_param = _type_param(transaction_type) + if type_param is not None: + params["type"] = type_param response = await self.api_client.get(endpoint=endpoint, params=params) page = response.json() sent = params.get("start") @@ -400,12 +417,58 @@ async def account_fa_transactions( sent is None and pagination is not None and pagination.ascending is True - and transaction_type is not None + and type_param is not None ) - return page, _next_cursor( + return page, next_cursor( response, page, sent=sent, zero_continues=zero_continues ) + def iter_account_fa_transactions( + self, + account_address: AccountAddress | str, + pagination: PaginationWithOrder | None = None, + metadata_address: AccountAddress | str = SUPRA_FA_METADATA_ADDRESS, + transaction_type: TransactionType | str | None = None, + *, + store_address: AccountAddress | str | None = None, + ) -> AsyncIterator[dict[str, Any]]: + """Iterates over all transactions of an account's fungible store, requesting further pages until the feed + ends. + + The iteration ends when the node sends no next cursor or repeats one (see + `supra_sdk.clients.rest.paging.next_cursor`). An empty page with a new cursor does not end it. + + Args: + account_address (AccountAddress | str): Address of the account that owns the store, as an + `AccountAddress` or a hex string. + pagination (PaginationWithOrder | None): Pagination options of the first request; `count` sets the page + size, `start` the cursor to begin from and `ascending` the order. Default to None. + metadata_address (AccountAddress | str): Address of the fungible asset's metadata object. Default to + `SUPRA_FA_METADATA_ADDRESS` (`0xa`). + transaction_type (TransactionType | str | None): Keeps only transactions of this type, as for + `account_fa_transactions`; `settlement` needs a v12 node. Default to None, which keeps every type. + store_address (AccountAddress | str | None): Address of a fungible store to query instead of the primary + store derived from `account_address` and `metadata_address`. Default to None. + + Returns: + AsyncIterator[dict[str, Any]]: The transactions, in the requested order. + + Raises: + ValueError: If an address is not a valid account address; raised when the iteration starts. + + """ + return paginate( + lambda p: self.account_fa_transactions( + account_address, + p, + metadata_address, + transaction_type, + store_address=store_address, + ), + pagination or PaginationWithOrder(), + lambda p, cursor: replace(p, start=cursor), + ) + async def account_automated_transactions( self, account_address: AccountAddress | str, @@ -458,3 +521,120 @@ def iter_account_automated_transactions( pagination or AutomatedTransactionsPagination(), lambda p, cursor: replace(p, cursor=cursor), ) + + async def account_history( + self, + account_address: AccountAddress | str, + pagination: PaginationWithOrder | None = None, + categories: Iterable[AccountHistoryCategory | str] | None = None, + ) -> tuple[list[dict[str, Any]], str | None]: + """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`). + + It lists the transfers of SUPRA held in primary fungible stores (category `fa`), which + `account_coin_transactions` does not. + + Needs a node from `supra_node_v12.0.0`. A node before v12 (Testnet and Mainnet until they run v12) answers + HTTP 404 with an empty body, raised as `supra_sdk.clients.api_client.ApiError` with `status_code` 404 and an + empty `message`. + + 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. + + 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 + exhausted. The history ends only when the response has no `x-supra-cursor` header, which the returned cursor + reports as `None`. An empty page with a cursor does not end it, and `"0"` is a valid cursor: the position of + a genesis-block row on a chain whose genesis timestamp is 0. Pass the returned cursor as + `PaginationWithOrder(start=cursor)` to read the next page. + + Args: + account_address (AccountAddress | str): Address of the account, as an `AccountAddress` or a hex string. + pagination (PaginationWithOrder | None): Pagination options: `count` is the number of entries to scan + for (node default 20, at most 100), `start` the cursor of the previous page (exclusive) and + `ascending` the order (node default descending). Default to None. + categories (Iterable[AccountHistoryCategory | str] | None): Return only transactions that matched at + least one of these categories: `sent` (the account sent it), `coin` (coins moved in or out of the + account), `fa` (a fungible asset store the account owned moved), `da` (a digital asset or object + event names the account), as `AccountHistoryCategory` members or their string values. A single + 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 + `account_coin_transactions` returns it. + - str | None: Cursor to retrieve the next page, or `None` when the history has ended. + + Raises: + ValueError: If `categories` is empty or names something other than `sent`, `coin`, `fa` or `da`. + + """ + endpoint = format_endpoint( + ACCOUNT_HISTORY_ENDPOINT, account_address=account_address + ) + params = pagination.to_params() if pagination else {} + if categories is not None: + params["categories"] = _history_categories_param(categories) + 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) + + def iter_account_history( + self, + account_address: AccountAddress | str, + pagination: PaginationWithOrder | None = None, + categories: Iterable[AccountHistoryCategory | str] | None = None, + ) -> AsyncIterator[dict[str, Any]]: + """Iterates over the history of an account, requesting further pages until the history ends. + + 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. + + Args: + account_address (AccountAddress | str): Address of the account, as an `AccountAddress` or a hex string. + pagination (PaginationWithOrder | None): Pagination options of the first request; `count` sets the page + size, `start` the cursor to begin from and `ascending` the order. Default to None. + categories (Iterable[AccountHistoryCategory | str] | None): Categories to return transactions of, as for + `account_history`. Default to None, which the node reads as all four. + + Returns: + AsyncIterator[dict[str, Any]]: The entries `{"categories": [...], "transaction": {...}}`, in the + requested order. + + """ + if categories is not None and not isinstance(categories, str): + categories = tuple(categories) + return paginate( + lambda p: self.account_history(account_address, p, categories), + pagination or PaginationWithOrder(), + lambda p, cursor: replace(p, start=cursor), + ) + + +def _history_categories_param( + categories: Iterable[AccountHistoryCategory | str], +) -> str: + """The `categories` query parameter of `account_history`: the category names, comma separated. + + Raises: + ValueError: If `categories` is empty or names something other than `sent`, `coin`, `fa` or `da`. + + """ + selected = [categories] if isinstance(categories, str) else categories + names = [] + for category in selected: + try: + names.append(AccountHistoryCategory(category).value) + except ValueError as e: + valid = ", ".join(c.value for c in AccountHistoryCategory) + raise ValueError(f"categories must name only {valid}: {category!r}") from e + if not names: + raise ValueError("categories must name at least one category") + return ",".join(names) diff --git a/supra_sdk/clients/rest/paging.py b/supra_sdk/clients/rest/paging.py index 4c16bdb..2c15a1a 100644 --- a/supra_sdk/clients/rest/paging.py +++ b/supra_sdk/clients/rest/paging.py @@ -15,6 +15,7 @@ def next_cursor( *, sent: Any = None, zero_continues: bool = False, + only_missing_header_ends: bool = False, ) -> str | None: """Reads the cursor of the next page from the `x-supra-cursor` response header, or `None` when the response ends the feed. @@ -35,6 +36,10 @@ def next_cursor( the `"0"`, and the next request continues with `start=0`. On a feed without rows the node answers that request with `"0"` again, which the rule for the request's own cursor ends, so this costs at most one more request. + A feed that only v12 nodes serve (account history) ends only when the header is absent or empty: + `only_missing_header_ends` returns any other header unchanged, on an empty page too, `"0"` and the request's own + cursor included. + Args: response (httpx.Response): Response of a paged request. page (list[Any]): The items of the page the response carried. @@ -43,6 +48,8 @@ def next_cursor( zero_continues (bool): True when the request had no cursor, was ascending and carried a `type` filter on a feed whose cursor `"0"` can be the position of a genesis row (coin and fungible asset transactions). Default to False. + only_missing_header_ends (bool): True for a feed that ends only by omitting the header, a feed that only + v12 nodes serve. `sent` and `zero_continues` are then not used. Default to False. Returns: str | None: The cursor to pass back to read the next page, or `None` when the feed has ended. @@ -51,7 +58,7 @@ def next_cursor( cursor = response.headers.get(CURSOR_HEADER, "") if not cursor.strip(): return None - if page: + if page or only_missing_header_ends: return cursor if cursor == "0" and zero_continues: return cursor diff --git a/supra_sdk/clients/rest/rest_types.py b/supra_sdk/clients/rest/rest_types.py index 13fc509..85ece9d 100644 --- a/supra_sdk/clients/rest/rest_types.py +++ b/supra_sdk/clients/rest/rest_types.py @@ -155,6 +155,7 @@ def format_endpoint(template: str, **values: Any) -> str: ACCOUNT_AUTOMATED_TRANSACTIONS_ENDPOINT = ( f"{RPC_API_PREFIX}/accounts/{{account_address}}/automated_transactions" ) +ACCOUNT_HISTORY_ENDPOINT = f"{RPC_API_PREFIX}/accounts/{{account_address}}/history" # Transaction endpoints CHAIN_ID_ENDPOINT = f"{RPC_API_PREFIX}/transactions/chain_id" @@ -333,6 +334,26 @@ def to_params(self) -> dict[str, Any]: return params +class AccountHistoryCategory(str, Enum): + """The categories an entry of an account's history can match, used as the `categories` filter of + `AccountRestClient.account_history`. + + Attributes: + SENT (str): The account sent the transaction. + COIN (str): The transaction withdrew coins from or deposited coins to the account. + FA (str): The transaction withdrew from or deposited to a fungible asset store the account owned at that + point in its block. + DA (str): A digital asset or object event of the transaction names the account: a `0x4` collection or token + event, an `0x1::object` transfer, or a legacy `0x3` token event. + + """ + + SENT = "sent" + COIN = "coin" + FA = "fa" + DA = "da" + + class TransactionType(str, Enum): """Enumeration of transaction types. diff --git a/tests/unit/test_paging.py b/tests/unit/test_paging.py index 6fb9669..3651a63 100644 --- a/tests/unit/test_paging.py +++ b/tests/unit/test_paging.py @@ -3,19 +3,21 @@ import unittest from collections.abc import Callable -from dataclasses import replace from typing import Any import httpx from supra_sdk.account_address import AccountAddress from supra_sdk.clients import ApiClient -from supra_sdk.clients.rest.paging import next_cursor, paginate +from supra_sdk.clients.api_client import ApiError +from supra_sdk.clients.rest.paging import next_cursor from supra_sdk.clients.rest.rest_types import ( + AccountHistoryCategory, AutomatedTransactionsPagination, EventsPagination, Pagination, PaginationWithOrder, + TransactionType, ) from supra_sdk.clients.rest.supra_client import SupraClient from supra_sdk.clients.supra_token_object import SupraTokenObjectClient @@ -125,6 +127,24 @@ def test_echo_of_the_sent_cursor_on_a_page_with_items_continues(self): def test_empty_sent_cursor_counts_as_none(self): self.assertEqual(next_cursor(self.response({CURSOR: "c"}), [], sent=""), "c") + def test_only_missing_header_ends_keeps_every_present_cursor(self): + for cursor in ("0", "abcd", "5"): + self.assertEqual( + next_cursor( + self.response({CURSOR: cursor}), + [], + sent="abcd", + only_missing_header_ends=True, + ), + cursor, + ) + self.assertIsNone( + next_cursor(self.response({}), [], only_missing_header_ends=True) + ) + self.assertIsNone( + next_cursor(self.response({CURSOR: ""}), [], only_missing_header_ends=True) + ) + class CursorReturnTests(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): @@ -355,6 +375,57 @@ async def test_coin_transactions_iterator_keeps_the_order_and_starts_from_start( self.assertEqual(self.node.params(0), {"start": "7", "ascending": "true"}) self.assertEqual(self.node.params(1), {"start": "8", "ascending": "true"}) + async def test_fa_transactions_iterator_ends_on_the_v12_missing_header(self): + pages = {None: page([1, 2], "100"), "100": page([3], None)} + self.node = await FakeNode.create( + lambda request: pages[request.url.params.get("start")] + ) + items = await collect( + self.node.client.iter_account_fa_transactions( + ADDRESS, PaginationWithOrder(count=2) + ) + ) + self.assertEqual(items, [1, 2, 3]) + self.assertEqual(len(self.node.requests), 2) + self.assertEqual(self.node.params(0), {"count": "2"}) + self.assertEqual(self.node.params(1), {"count": "2", "start": "100"}) + for request in self.node.requests: + self.assertTrue(request.url.path.endswith("/fa_transactions")) + + async def test_fa_transactions_iterator_continues_past_an_empty_filtered_page( + self, + ): + pages = { + None: page([1], "100"), + "100": page([], "90"), + "90": page([2], "80"), + "80": page([], "0"), + } + self.node = await FakeNode.create( + lambda request: pages[request.url.params.get("start")] + ) + items = await collect( + self.node.client.iter_account_fa_transactions( + ADDRESS, None, "0xa", TransactionType.USER + ) + ) + self.assertEqual(items, [1, 2]) + self.assertEqual(len(self.node.requests), 4) + for index in range(4): + self.assertEqual(self.node.params(index)["type"], "user") + + async def test_fa_transactions_iterator_queries_the_given_store(self): + self.node = await FakeNode.create(lambda _: page([1], None)) + await collect( + self.node.client.iter_account_fa_transactions( + ADDRESS, store_address="0xabc" + ) + ) + self.assertEqual( + self.node.requests[0].url.path, + f"/rpc/v3/accounts/{AccountAddress.from_str_relaxed('0xabc')}/fa_transactions", + ) + async def test_automated_transactions_pass_the_cursor_back(self): pages = { None: page([1, 2], "k1"), @@ -456,46 +527,201 @@ async def test_iterator_is_lazy(self): class TypedAscendingFeedTests(unittest.IsolatedAsyncioTestCase): - """`paginate` over a feed read in ascending order with a `type` filter, where `"0"` on the first empty page can - be a genesis row's position. No feed on this client takes a `type` filter yet; the fetch is built the way such a - feed would use `next_cursor`.""" - - def feed(self, responses: dict[str | None, httpx.Response]): - self.sent: list[str | None] = [] - - async def fetch(p: PaginationWithOrder) -> tuple[list[Any], str | None]: - self.sent.append(p.start) - response = responses[p.start] - items = response.json() - zero_continues = p.start is None and p.ascending is True - return items, next_cursor( - response, items, sent=p.start, zero_continues=zero_continues + """Coin transactions read in ascending order with a `type` filter, where `"0"` on the first empty page can be a + genesis row's position.""" + + async def asyncTearDown(self): + await self.node.close() + + async def feed( + self, + responses: dict[str | None, httpx.Response], + transaction_type: TransactionType | str | None = TransactionType.USER, + ascending: bool | None = True, + ) -> list[Any]: + self.node = await FakeNode.create( + lambda request: responses[request.url.params.get("start")] + ) + return await collect( + self.node.client.iter_account_coin_transactions( + ADDRESS, PaginationWithOrder(ascending=ascending), transaction_type ) + ) - return paginate( - fetch, - PaginationWithOrder(ascending=True), - lambda p, cursor: replace(p, start=cursor), + def sent(self) -> list[str | None]: + return [request.url.params.get("start") for request in self.node.requests] + + async def test_the_filter_is_sent_as_the_type_parameter(self): + await self.feed({None: page([], None)}, "settlement") + self.assertEqual( + self.node.params(0), {"ascending": "true", "type": "settlement"} ) + await self.node.close() + await self.feed({None: page([], None)}, TransactionType.RECORD, None) + self.assertEqual(self.node.params(0), {"type": "record"}) async def test_zero_continues_once_and_its_echo_ends_the_feed(self): - items = await collect(self.feed({None: page([], "0"), "0": page([], "0")})) + items = await self.feed({None: page([], "0"), "0": page([], "0")}) self.assertEqual(items, []) - self.assertEqual(self.sent, [None, "0"]) + self.assertEqual(self.sent(), [None, "0"]) + self.assertEqual( + self.node.params(1), {"start": "0", "ascending": "true", "type": "user"} + ) async def test_zero_continues_to_the_rows_after_a_genesis_row(self): - items = await collect( - self.feed( - {None: page([], "0"), "0": page([1, 2], "7"), "7": page([], None)} - ) + items = await self.feed( + {None: page([], "0"), "0": page([1, 2], "7"), "7": page([], None)} ) self.assertEqual(items, [1, 2]) - self.assertEqual(self.sent, [None, "0", "7"]) + self.assertEqual(self.sent(), [None, "0", "7"]) async def test_zero_on_a_later_empty_page_ends_the_feed(self): - items = await collect(self.feed({None: page([1], "7"), "7": page([], "0")})) + items = await self.feed({None: page([1], "7"), "7": page([], "0")}) self.assertEqual(items, [1]) - self.assertEqual(self.sent, [None, "7"]) + self.assertEqual(self.sent(), [None, "7"]) + + async def test_zero_ends_the_feed_without_a_filter(self): + items = await self.feed({None: page([], "0")}, None) + self.assertEqual(items, []) + self.assertEqual(self.sent(), [None]) + + async def test_zero_ends_a_descending_filtered_feed(self): + items = await self.feed({None: page([], "0")}, "user", False) + self.assertEqual(items, []) + self.assertEqual(self.sent(), [None]) + + async def test_zero_continues_only_for_the_first_request(self): + self.node = await FakeNode.create(lambda _: page([], "0")) + self.assertEqual( + await self.node.client.account_coin_transactions( + ADDRESS, PaginationWithOrder(start="5", ascending=True), "user" + ), + ([], None), + ) + self.assertEqual( + await self.node.client.account_coin_transactions( + ADDRESS, PaginationWithOrder(ascending=True), "user" + ), + ([], "0"), + ) + + async def test_an_empty_filter_is_not_sent_and_zero_ends_the_feed(self): + items = await self.feed({None: page([], "0")}, "") + self.assertEqual(items, []) + self.assertEqual(self.sent(), [None]) + self.assertEqual(self.node.params(0), {"ascending": "true"}) + + async def test_an_empty_fa_filter_is_not_sent_and_zero_ends_the_feed(self): + self.node = await FakeNode.create(lambda _: page([], "0")) + result = await self.node.client.account_fa_transactions( + ADDRESS, PaginationWithOrder(ascending=True), transaction_type="" + ) + self.assertEqual(result, ([], None)) + self.assertEqual(self.node.params(0), {"ascending": "true"}) + + +class AccountHistoryTests(unittest.IsolatedAsyncioTestCase): + async def asyncTearDown(self): + await self.node.close() + + async def test_requests_the_history_route_with_the_options(self): + self.node = await FakeNode.create(lambda _: page([], None)) + await self.node.client.account_history( + "0x1", + PaginationWithOrder(count=50, start="12", ascending=True), + [AccountHistoryCategory.SENT, "fa"], + ) + self.assertEqual(self.node.requests[0].url.path, "/rpc/v3/accounts/0x1/history") + self.assertEqual( + self.node.params(0), + { + "count": "50", + "start": "12", + "ascending": "true", + "categories": "sent,fa", + }, + ) + + async def test_sends_no_options_by_default(self): + self.node = await FakeNode.create(lambda _: page([], None)) + await self.node.client.account_history(ADDRESS) + self.assertEqual(self.node.params(0), {}) + + async def test_a_single_category_is_accepted(self): + self.node = await FakeNode.create(lambda _: page([], None)) + await self.node.client.account_history(ADDRESS, categories="coin") + await self.node.client.account_history( + ADDRESS, categories=AccountHistoryCategory.DA + ) + self.assertEqual(self.node.params(0), {"categories": "coin"}) + self.assertEqual(self.node.params(1), {"categories": "da"}) + + async def test_invalid_categories_are_rejected_before_a_request(self): + self.node = await FakeNode.create(lambda _: page([], None)) + for categories in ([], ["sent", "automated"], "sent,fa", ["SENT"]): + with self.assertRaises(ValueError): + await self.node.client.account_history(ADDRESS, categories=categories) + self.assertEqual(self.node.requests, []) + + 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.response = page([], "7") + self.assertEqual( + await client.account_history(ADDRESS, PaginationWithOrder(start="7")), + ([], "7"), + ) + self.response = page([{"n": 1}], "0") + self.assertEqual(await client.account_history(ADDRESS), ([{"n": 1}], "0")) + self.response = page([{"n": 1}], None) + self.assertEqual(await client.account_history(ADDRESS), ([{"n": 1}], None)) + self.response = page([], "") + self.assertEqual(await client.account_history(ADDRESS), ([], None)) + + async def test_a_node_without_the_route_raises_api_error(self): + self.node = await FakeNode.create(lambda _: httpx.Response(404)) + with self.assertRaises(ApiError) as raised: + await self.node.client.account_history(ADDRESS) + self.assertEqual(raised.exception.status_code, 404) + self.assertEqual(raised.exception.message, "") + + async def test_iterator_pages_past_an_empty_page_and_the_zero_cursor(self): + # The node filters by category while it scans: a page can be empty and carry a cursor, and "0" is the + # cursor of a genesis-block row. Ascending, the first scan reads only that row, which the filter excludes. + pages = { + None: page([], "0"), + "0": page([1], "9"), + "9": page([], "14"), + "14": page([2], None), + } + self.node = await FakeNode.create( + lambda request: pages[request.url.params.get("start")] + ) + items = await collect( + self.node.client.iter_account_history( + ADDRESS, + PaginationWithOrder(count=1, ascending=True), + (category for category in ["fa", "sent"]), + ) + ) + self.assertEqual(items, [1, 2]) + self.assertEqual(len(self.node.requests), 4) + self.assertEqual( + self.node.params(0), + {"count": "1", "ascending": "true", "categories": "fa,sent"}, + ) + self.assertEqual( + self.node.params(1), + {"count": "1", "start": "0", "ascending": "true", "categories": "fa,sent"}, + ) + + async def test_iterator_stops_when_a_cursor_repeats(self): + self.node = await FakeNode.create(lambda _: page([], "0")) + items = await collect(self.node.client.iter_account_history(ADDRESS)) + self.assertEqual(items, []) + self.assertEqual(len(self.node.requests), 2) class ReadObjectTests(unittest.IsolatedAsyncioTestCase): diff --git a/tests/unit/test_supra_client.py b/tests/unit/test_supra_client.py index 0963b28..685ffab 100644 --- a/tests/unit/test_supra_client.py +++ b/tests/unit/test_supra_client.py @@ -272,8 +272,8 @@ async def test_account_coin_transactions(self): if uses_coin_store: self.assertEqual(len(page), 1) else: - # An empty listing carries no cursor. - self.assertEqual((page, cursor), ([], "")) + # The method reports the end of the feed as a `None` cursor. + self.assertEqual((page, cursor), ([], None)) # The deposit event type follows the receiver's layout, not the sender's. receiver = AccountAddress.from_str("0x1")