From 5ee3a3b370d155f3a3729444a6d5a5cc4a7f46f6 Mon Sep 17 00:00:00 2001 From: Joe Polny <50534337+joe-p@users.noreply.github.com> Date: Fri, 26 Apr 2024 09:08:27 -0400 Subject: [PATCH] feat: AlgorandClient (#71) * initial composer * account and client manager * build_group and execute * WIP: algorand-client * fix up algorand-client * algorand-client -> algorand_client * test_send_payment * refactor params dataclasses * fix sender params * refactor txn params * rm AlgoAmount * beta namespace * rm from __init__ * improve send_payment * test_asset_opt_in * addr -> address * parity with JS tests * ruff check --fix * move account_manager to beta * unsafe ruff fixes * various fixes * use match * fix remaining ruff errors (other than line length and comments) * assert rather than cast * dont import from source * use frozen dataclasses * default get value * instantiate dict * using typing.Self * fix some docstrings * update idna due to vulnerability by pip-audit * ruff * ignore ruff errors in beta for now * fix non sdk mypy stuff * update cryptography for pip-audit * rm comment * ignore mypy errors * ruff * update setuptools * chore: hotfixing the pkgutil.ImpImporter error when using 3.12 based pipx; adding dependabot; patching snapshot tests (#72) * fix: testing ci * chore: testing ci * chore: test * chore: test * chore: testing ci * chore: testing ci * chore: test * chore: testing * chore: test * chore: test * chore: removing tmp tweak * chore: testing ci * chore: lockfile maintenance (poetry update); reverting ci tweaks * chore: testing ci * chore: testing ci * chore: testing ci * tuple unpacking --------- Co-authored-by: Al Co-authored-by: Altynbek Orumbayev --- docs/source/conf.py | 4 +- pyproject.toml | 4 + src/algokit_utils/beta/account_manager.py | 200 ++++++ src/algokit_utils/beta/algorand_client.py | 319 ++++++++++ src/algokit_utils/beta/client_manager.py | 78 +++ src/algokit_utils/beta/composer.py | 716 ++++++++++++++++++++++ src/algokit_utils/network_clients.py | 17 + tests/app_algorand_client.json | 179 ++++++ tests/test_algorand_client.py | 222 +++++++ 9 files changed, 1737 insertions(+), 2 deletions(-) create mode 100644 src/algokit_utils/beta/account_manager.py create mode 100644 src/algokit_utils/beta/algorand_client.py create mode 100644 src/algokit_utils/beta/client_manager.py create mode 100644 src/algokit_utils/beta/composer.py create mode 100644 tests/app_algorand_client.json create mode 100644 tests/test_algorand_client.py diff --git a/docs/source/conf.py b/docs/source/conf.py index 16333dd9..5d03ef7f 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -23,7 +23,7 @@ "autodoc2", ] templates_path = ["_templates"] -exclude_patterns = [] +exclude_patterns = [] # type: ignore intersphinx_mapping = { "python": ("https://docs.python.org/3", None), "algosdk": ("https://py-algorand-sdk.readthedocs.io/en/latest", None), @@ -37,7 +37,7 @@ # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output html_theme = "sphinx_rtd_theme" -html_static_path = [] +html_static_path = [] # type: ignore # -- Options for myst --- diff --git a/pyproject.toml b/pyproject.toml index 10b4e7b4..1f9e5a1f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -119,6 +119,10 @@ target-version = "py310" allow-star-arg-any = true suppress-none-returning = true +[tool.ruff.per-file-ignores] +"src/algokit_utils/beta/*" = ["ERA001", "E501", "PLR0911"] +"path/to/file.py" = ["E402"] + [tool.poe.tasks] docs = "sphinx-build docs/source docs/html" diff --git a/src/algokit_utils/beta/account_manager.py b/src/algokit_utils/beta/account_manager.py new file mode 100644 index 00000000..7eddff75 --- /dev/null +++ b/src/algokit_utils/beta/account_manager.py @@ -0,0 +1,200 @@ +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from algokit_utils.account import get_dispenser_account, get_kmd_wallet_account, get_localnet_default_account +from algosdk.account import generate_account +from algosdk.atomic_transaction_composer import AccountTransactionSigner, TransactionSigner +from typing_extensions import Self + +from .client_manager import ClientManager + + +@dataclass +class AddressAndSigner: + address: str + signer: TransactionSigner + + +class AccountManager: + """Creates and keeps track of addresses and signers""" + + def __init__(self, client_manager: ClientManager): + """ + Create a new account manager. + + :param client_manager: The ClientManager client to use for algod and kmd clients + """ + self._client_manager = client_manager + self._accounts = dict[str, TransactionSigner]() + self._default_signer: TransactionSigner | None = None + + def set_default_signer(self, signer: TransactionSigner) -> Self: + """ + Sets the default signer to use if no other signer is specified. + + :param signer: The signer to use, either a `TransactionSigner` or a `TransactionSignerAccount` + :return: The `AccountManager` so method calls can be chained + """ + self._default_signer = signer + return self + + def set_signer(self, sender: str, signer: TransactionSigner) -> Self: + """ + Tracks the given account for later signing. + + :param sender: The sender address to use this signer for + :param signer: The signer to sign transactions with for the given sender + :return: The AccountCreator instance for method chaining + """ + self._accounts[sender] = signer + return self + + def get_signer(self, sender: str) -> TransactionSigner: + """ + Returns the `TransactionSigner` for the given sender address. + + If no signer has been registered for that address then the default signer is used if registered. + + :param sender: The sender address + :return: The `TransactionSigner` or throws an error if not found + """ + signer = self._accounts.get(sender, None) or self._default_signer + if not signer: + raise ValueError(f"No signer found for address {sender}") + return signer + + def get_information(self, sender: str) -> dict[str, Any]: + """ + Returns the given sender account's current status, balance and spendable amounts. + + Example: + address = "XBYLS2E6YI6XXL5BWCAMOA4GTWHXWENZMX5UHXMRNWWUQ7BXCY5WC5TEPA" + account_info = account.get_information(address) + + `Response data schema details `_ + + :param sender: The address of the sender/account to look up + :return: The account information + """ + info = self._client_manager.algod.account_info(sender) + assert isinstance(info, dict) + return info + + def get_asset_information(self, sender: str, asset_id: int) -> dict[str, Any]: + info = self._client_manager.algod.account_asset_info(sender, asset_id) + assert isinstance(info, dict) + return info + + # TODO + # def from_mnemonic(self, mnemonic_secret: str, sender: Optional[str] = None) -> AddrAndSigner: + # """ + # Tracks and returns an Algorand account with secret key loaded (i.e. that can sign transactions) by taking the mnemonic secret. + + # Example: + # account = account.from_mnemonic("mnemonic secret ...") + # rekeyed_account = account.from_mnemonic("mnemonic secret ...", "SENDERADDRESS...") + + # :param mnemonic_secret: The mnemonic secret representing the private key of an account; **Note: Be careful how the mnemonic is handled**, + # never commit it into source control and ideally load it from the environment (ideally via a secret storage service) rather than the file system. + # :param sender: The optional sender address to use this signer for (aka a rekeyed account) + # :return: The account + # """ + # account = mnemonic_account(mnemonic_secret) + # return self.signer_account(rekeyed_account(account, sender) if sender else account) + + def from_kmd( + self, + name: str, + predicate: Callable[[dict[str, Any]], bool] | None = None, + ) -> AddressAndSigner: + """ + Tracks and returns an Algorand account with private key loaded from the given KMD wallet (identified by name). + + Example (Get default funded account in a LocalNet): + default_dispenser_account = account.from_kmd('unencrypted-default-wallet', + lambda a: a['status'] != 'Offline' and a['amount'] > 1_000_000_000 + ) + + :param name: The name of the wallet to retrieve an account from + :param predicate: An optional filter to use to find the account (otherwise it will return a random account from the wallet) + :return: The account + """ + account = get_kmd_wallet_account( + name=name, predicate=predicate, client=self._client_manager.algod, kmd_client=self._client_manager.kmd + ) + if not account: + raise ValueError(f"Unable to find KMD account {name}{' with predicate' if predicate else ''}") + + self.set_signer(account.address, account.signer) + return AddressAndSigner(address=account.address, signer=account.signer) + + # TODO + # def multisig( + # self, multisig_params: algosdk.MultisigMetadata, signing_accounts: Union[algosdk.Account, SigningAccount] + # ) -> TransactionSignerAccount: + # """ + # Tracks and returns an account that supports partial or full multisig signing. + + # Example: + # account = account.multisig( + # { + # "version": 1, + # "threshold": 1, + # "addrs": ["ADDRESS1...", "ADDRESS2..."] + # }, + # account.from_environment('ACCOUNT1') + # ) + + # :param multisig_params: The parameters that define the multisig account + # :param signing_accounts: The signers that are currently present + # :return: A multisig account wrapper + # """ + # return self.signer_account(multisig_account(multisig_params, signing_accounts)) + + def random(self) -> AddressAndSigner: + """ + Tracks and returns a new, random Algorand account with secret key loaded. + + Example: + account = account.random() + + :return: The account + """ + (sk, addr) = generate_account() # type: ignore[no-untyped-call] + signer = AccountTransactionSigner(sk) + + self.set_signer(addr, signer) + + return AddressAndSigner(address=addr, signer=signer) + + def dispenser(self) -> AddressAndSigner: + """ + Returns an account (with private key loaded) that can act as a dispenser. + + Example: + account = account.dispenser() + + If running on LocalNet then it will return the default dispenser account automatically, + otherwise it will load the account mnemonic stored in os.environ['DISPENSER_MNEMONIC']. + + :return: The account + """ + acct = get_dispenser_account(self._client_manager.algod) + + self.set_signer(acct.address, acct.signer) + + return AddressAndSigner(address=acct.address, signer=acct.signer) + + def localnet_dispenser(self) -> AddressAndSigner: + """ + Returns an Algorand account with private key loaded for the default LocalNet dispenser account (that can be used to fund other accounts). + + Example: + account = account.localnet_dispenser() + + :return: The account + """ + acct = get_localnet_default_account(self._client_manager.algod) + self.set_signer(acct.address, acct.signer) + return AddressAndSigner(address=acct.address, signer=acct.signer) diff --git a/src/algokit_utils/beta/algorand_client.py b/src/algokit_utils/beta/algorand_client.py new file mode 100644 index 00000000..e80dadaf --- /dev/null +++ b/src/algokit_utils/beta/algorand_client.py @@ -0,0 +1,319 @@ +import copy +import time +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from algokit_utils.beta.account_manager import AccountManager +from algokit_utils.beta.client_manager import AlgoSdkClients, ClientManager +from algokit_utils.beta.composer import ( + AlgokitComposer, + AppCallParams, + AssetConfigParams, + AssetCreateParams, + AssetDestroyParams, + AssetFreezeParams, + AssetOptInParams, + AssetTransferParams, + MethodCallParams, + OnlineKeyRegParams, + PayParams, +) +from algokit_utils.network_clients import ( + AlgoClientConfigs, + get_algod_client, + get_algonode_config, + get_default_localnet_config, + get_indexer_client, + get_kmd_client, +) +from algosdk.atomic_transaction_composer import AtomicTransactionResponse, TransactionSigner +from algosdk.transaction import SuggestedParams, Transaction, wait_for_confirmation +from typing_extensions import Self + +__all__ = [ + "AlgorandClient", + "AssetCreateParams", + "AssetOptInParams", + "MethodCallParams", + "PayParams", + "AssetFreezeParams", + "AssetConfigParams", + "AssetDestroyParams", + "AppCallParams", + "OnlineKeyRegParams", + "AssetTransferParams", +] + + +@dataclass +class AlgorandClientSendMethods: + """ + Methods used to send a transaction to the network and wait for confirmation + """ + + payment: Callable[[PayParams], dict[str, Any]] + asset_create: Callable[[AssetCreateParams], dict[str, Any]] + asset_config: Callable[[AssetConfigParams], dict[str, Any]] + asset_freeze: Callable[[AssetFreezeParams], dict[str, Any]] + asset_destroy: Callable[[AssetDestroyParams], dict[str, Any]] + asset_transfer: Callable[[AssetTransferParams], dict[str, Any]] + app_call: Callable[[AppCallParams], dict[str, Any]] + online_key_reg: Callable[[OnlineKeyRegParams], dict[str, Any]] + method_call: Callable[[MethodCallParams], dict[str, Any]] + asset_opt_in: Callable[[AssetOptInParams], dict[str, Any]] + + +@dataclass +class AlgorandClientTransactionMethods: + """ + Methods used to form a transaction without signing or sending to the network + """ + + payment: Callable[[PayParams], Transaction] + asset_create: Callable[[AssetCreateParams], Transaction] + asset_config: Callable[[AssetConfigParams], Transaction] + asset_freeze: Callable[[AssetFreezeParams], Transaction] + asset_destroy: Callable[[AssetDestroyParams], Transaction] + asset_transfer: Callable[[AssetTransferParams], Transaction] + app_call: Callable[[AppCallParams], Transaction] + online_key_reg: Callable[[OnlineKeyRegParams], Transaction] + method_call: Callable[[MethodCallParams], list[Transaction]] + asset_opt_in: Callable[[AssetOptInParams], Transaction] + + +class AlgorandClient: + """A client that brokers easy access to Algorand functionality.""" + + def __init__(self, config: AlgoClientConfigs | AlgoSdkClients): + self._client_manager: ClientManager = ClientManager(config) + self._account_manager: AccountManager = AccountManager(self._client_manager) + + self._cached_suggested_params: SuggestedParams | None = None + self._cached_suggested_params_expiry: float | None = None + self._cached_suggested_params_timeout: int = 3_000 # three seconds + + self._default_validity_window: int = 10 + + def _unwrap_single_send_result(self, results: AtomicTransactionResponse) -> dict[str, Any]: + return { + "confirmation": wait_for_confirmation(self._client_manager.algod, results.tx_ids[0]), + "tx_id": results.tx_ids[0], + } + + def set_default_validity_window(self, validity_window: int) -> Self: + """ + Sets the default validity window for transactions. + + :param validity_window: The number of rounds between the first and last valid rounds + :return: The `AlgorandClient` so method calls can be chained + """ + self._default_validity_window = validity_window + return self + + def set_default_signer(self, signer: TransactionSigner) -> Self: + """ + Sets the default signer to use if no other signer is specified. + + :param signer: The signer to use, either a `TransactionSigner` or a `TransactionSignerAccount` + :return: The `AlgorandClient` so method calls can be chained + """ + self._account_manager.set_default_signer(signer) + return self + + def set_signer(self, sender: str, signer: TransactionSigner) -> Self: + """ + Tracks the given account for later signing. + + :param sender: The sender address to use this signer for + :param signer: The signer to sign transactions with for the given sender + :return: The `AlgorandClient` so method calls can be chained + """ + self._account_manager.set_signer(sender, signer) + return self + + def set_suggested_params(self, suggested_params: SuggestedParams, until: float | None = None) -> Self: + """ + Sets a cache value to use for suggested params. + + :param suggested_params: The suggested params to use + :param until: A timestamp until which to cache, or if not specified then the timeout is used + :return: The `AlgorandClient` so method calls can be chained + """ + self._cached_suggested_params = suggested_params + self._cached_suggested_params_expiry = until or time.time() + self._cached_suggested_params_timeout + return self + + def set_suggested_params_timeout(self, timeout: int) -> Self: + """ + Sets the timeout for caching suggested params. + + :param timeout: The timeout in milliseconds + :return: The `AlgorandClient` so method calls can be chained + """ + self._cached_suggested_params_timeout = timeout + return self + + def get_suggested_params(self) -> SuggestedParams: + """Get suggested params for a transaction (either cached or from algod if the cache is stale or empty)""" + if self._cached_suggested_params and ( + self._cached_suggested_params_expiry is None or self._cached_suggested_params_expiry > time.time() + ): + return copy.deepcopy(self._cached_suggested_params) + + self._cached_suggested_params = self._client_manager.algod.suggested_params() + self._cached_suggested_params_expiry = time.time() + self._cached_suggested_params_timeout + + return copy.deepcopy(self._cached_suggested_params) + + @property + def client(self) -> ClientManager: + """Get clients, including algosdk clients and app clients.""" + return self._client_manager + + @property + def account(self) -> AccountManager: + """Get or create accounts that can sign transactions.""" + return self._account_manager + + def new_group(self) -> AlgokitComposer: + """Start a new `AlgokitComposer` transaction group""" + return AlgokitComposer( + algod=self.client.algod, + get_signer=lambda addr: self.account.get_signer(addr), + get_suggested_params=self.get_suggested_params, + default_validity_window=self._default_validity_window, + ) + + @property + def send(self) -> AlgorandClientSendMethods: + """Methods for sending a transaction and waiting for confirmation""" + return AlgorandClientSendMethods( + payment=lambda params: self._unwrap_single_send_result(self.new_group().add_payment(params).execute()), + asset_create=lambda params: self._unwrap_single_send_result( + self.new_group().add_asset_create(params).execute() + ), + asset_config=lambda params: self._unwrap_single_send_result( + self.new_group().add_asset_config(params).execute() + ), + asset_freeze=lambda params: self._unwrap_single_send_result( + self.new_group().add_asset_freeze(params).execute() + ), + asset_destroy=lambda params: self._unwrap_single_send_result( + self.new_group().add_asset_destroy(params).execute() + ), + asset_transfer=lambda params: self._unwrap_single_send_result( + self.new_group().add_asset_transfer(params).execute() + ), + app_call=lambda params: self._unwrap_single_send_result(self.new_group().add_app_call(params).execute()), + online_key_reg=lambda params: self._unwrap_single_send_result( + self.new_group().add_online_key_reg(params).execute() + ), + method_call=lambda params: self._unwrap_single_send_result( + self.new_group().add_method_call(params).execute() + ), + asset_opt_in=lambda params: self._unwrap_single_send_result( + self.new_group().add_asset_opt_in(params).execute() + ), + ) + + @property + def transactions(self) -> AlgorandClientTransactionMethods: + """Methods for building transactions""" + + return AlgorandClientTransactionMethods( + payment=lambda params: self.new_group().add_payment(params).build_group()[0].txn, + asset_create=lambda params: self.new_group().add_asset_create(params).build_group()[0].txn, + asset_config=lambda params: self.new_group().add_asset_config(params).build_group()[0].txn, + asset_freeze=lambda params: self.new_group().add_asset_freeze(params).build_group()[0].txn, + asset_destroy=lambda params: self.new_group().add_asset_destroy(params).build_group()[0].txn, + asset_transfer=lambda params: self.new_group().add_asset_transfer(params).build_group()[0].txn, + app_call=lambda params: self.new_group().add_app_call(params).build_group()[0].txn, + online_key_reg=lambda params: self.new_group().add_online_key_reg(params).build_group()[0].txn, + method_call=lambda params: [txn.txn for txn in self.new_group().add_method_call(params).build_group()], + asset_opt_in=lambda params: self.new_group().add_asset_opt_in(params).build_group()[0].txn, + ) + + @staticmethod + def default_local_net() -> "AlgorandClient": + """ + Returns an `AlgorandClient` pointing at default LocalNet ports and API token. + + :return: The `AlgorandClient` + """ + return AlgorandClient( + AlgoClientConfigs( + algod_config=get_default_localnet_config("algod"), + indexer_config=get_default_localnet_config("indexer"), + kmd_config=get_default_localnet_config("kmd"), + ) + ) + + @staticmethod + def test_net() -> "AlgorandClient": + """ + Returns an `AlgorandClient` pointing at TestNet using AlgoNode. + + :return: The `AlgorandClient` + """ + return AlgorandClient( + AlgoClientConfigs( + algod_config=get_algonode_config("testnet", "algod", ""), + indexer_config=get_algonode_config("testnet", "indexer", ""), + kmd_config=None, + ) + ) + + @staticmethod + def main_net() -> "AlgorandClient": + """ + Returns an `AlgorandClient` pointing at MainNet using AlgoNode. + + :return: The `AlgorandClient` + """ + return AlgorandClient( + AlgoClientConfigs( + algod_config=get_algonode_config("mainnet", "algod", ""), + indexer_config=get_algonode_config("mainnet", "indexer", ""), + kmd_config=None, + ) + ) + + @staticmethod + def from_clients(clients: AlgoSdkClients) -> "AlgorandClient": + """ + Returns an `AlgorandClient` pointing to the given client(s). + + :param clients: The clients to use + :return: The `AlgorandClient` + """ + return AlgorandClient(clients) + + @staticmethod + def from_environment() -> "AlgorandClient": + """ + Returns an `AlgorandClient` loading the configuration from environment variables. + + Retrieve configurations from environment variables when defined or get defaults. + + Expects to be called from a Python environment. + + :return: The `AlgorandClient` + """ + return AlgorandClient( + AlgoSdkClients( + algod=get_algod_client(), + kmd=get_kmd_client(), + indexer=get_indexer_client(), + ) + ) + + @staticmethod + def from_config(config: AlgoClientConfigs) -> "AlgorandClient": + """ + Returns an `AlgorandClient` from the given config. + + :param config: The config to use + :return: The `AlgorandClient` + """ + return AlgorandClient(config) diff --git a/src/algokit_utils/beta/client_manager.py b/src/algokit_utils/beta/client_manager.py new file mode 100644 index 00000000..1069eacf --- /dev/null +++ b/src/algokit_utils/beta/client_manager.py @@ -0,0 +1,78 @@ +import algosdk +from algokit_utils.dispenser_api import TestNetDispenserApiClient +from algokit_utils.network_clients import AlgoClientConfigs, get_algod_client, get_indexer_client, get_kmd_client +from algosdk.kmd import KMDClient +from algosdk.v2client.algod import AlgodClient +from algosdk.v2client.indexer import IndexerClient + + +class AlgoSdkClients: + """ + Clients from algosdk that interact with the official Algorand APIs. + + Attributes: + algod (AlgodClient): Algod client, see https://developer.algorand.org/docs/rest-apis/algod/ + indexer (Optional[IndexerClient]): Optional indexer client, see https://developer.algorand.org/docs/rest-apis/indexer/ + kmd (Optional[KMDClient]): Optional KMD client, see https://developer.algorand.org/docs/rest-apis/kmd/ + """ + + def __init__( + self, + algod: algosdk.v2client.algod.AlgodClient, + indexer: IndexerClient | None = None, + kmd: KMDClient | None = None, + ): + self.algod = algod + self.indexer = indexer + self.kmd = kmd + + +class ClientManager: + """ + Exposes access to various API clients. + + Args: + clients_or_config (Union[AlgoConfig, AlgoSdkClients]): algosdk clients or config for interacting with the official Algorand APIs. + """ + + def __init__(self, clients_or_configs: AlgoClientConfigs | AlgoSdkClients): + if isinstance(clients_or_configs, AlgoSdkClients): + _clients = clients_or_configs + elif isinstance(clients_or_configs, AlgoClientConfigs): + _clients = AlgoSdkClients( + algod=get_algod_client(clients_or_configs.algod_config), + indexer=get_indexer_client(clients_or_configs.indexer_config) + if clients_or_configs.indexer_config + else None, + kmd=get_kmd_client(clients_or_configs.kmd_config) if clients_or_configs.kmd_config else None, + ) + self._algod = _clients.algod + self._indexer = _clients.indexer + self._kmd = _clients.kmd + + @property + def algod(self) -> AlgodClient: + """Returns an algosdk Algod API client.""" + return self._algod + + @property + def indexer(self) -> IndexerClient: + """Returns an algosdk Indexer API client or raises an error if it's not been provided.""" + if not self._indexer: + raise ValueError("Attempt to use Indexer client in AlgoKit instance with no Indexer configured") + return self._indexer + + @property + def kmd(self) -> KMDClient: + """Returns an algosdk KMD API client or raises an error if it's not been provided.""" + if not self._kmd: + raise ValueError("Attempt to use Kmd client in AlgoKit instance with no Kmd configured") + return self._kmd + + def get_testnet_dispenser( + self, auth_token: str | None = None, request_timeout: int | None = None + ) -> TestNetDispenserApiClient: + if request_timeout: + return TestNetDispenserApiClient(auth_token=auth_token, request_timeout=request_timeout) + + return TestNetDispenserApiClient(auth_token=auth_token) diff --git a/src/algokit_utils/beta/composer.py b/src/algokit_utils/beta/composer.py new file mode 100644 index 00000000..3defc3f7 --- /dev/null +++ b/src/algokit_utils/beta/composer.py @@ -0,0 +1,716 @@ +from collections.abc import Callable +from dataclasses import dataclass +from typing import Union + +import algosdk +from algosdk.abi import Method +from algosdk.atomic_transaction_composer import ( + AtomicTransactionComposer, + AtomicTransactionResponse, + TransactionSigner, + TransactionWithSigner, +) +from algosdk.box_reference import BoxReference +from algosdk.transaction import OnComplete +from algosdk.v2client.algod import AlgodClient + + +@dataclass(frozen=True) +class SenderParam: + sender: str + + +@dataclass(frozen=True) +class CommonTxnParams: + """ + Common transaction parameters. + + :param signer: The function used to sign transactions. + :param rekey_to: Change the signing key of the sender to the given address. + :param note: Note to attach to the transaction. + :param lease: Prevent multiple transactions with the same lease being included within the validity window. + :param static_fee: The transaction fee. In most cases you want to use `extra_fee` unless setting the fee to 0 to be covered by another transaction. + :param extra_fee: The fee to pay IN ADDITION to the suggested fee. Useful for covering inner transaction fees. + :param max_fee: Throw an error if the fee for the transaction is more than this amount. + :param validity_window: How many rounds the transaction should be valid for. + :param first_valid_round: Set the first round this transaction is valid. If left undefined, the value from algod will be used. Only set this when you intentionally want this to be some time in the future. + :param last_valid_round: The last round this transaction is valid. It is recommended to use validity_window instead. + """ + + signer: TransactionSigner | None = None + rekey_to: str | None = None + note: bytes | None = None + lease: bytes | None = None + static_fee: int | None = None + extra_fee: int | None = None + max_fee: int | None = None + validity_window: int | None = None + first_valid_round: int | None = None + last_valid_round: int | None = None + + +@dataclass(frozen=True) +class _RequiredPayTxnParams(SenderParam): + receiver: str + amount: int + + +@dataclass(frozen=True) +class PayParams(CommonTxnParams, _RequiredPayTxnParams): + """ + Payment transaction parameters. + + :param receiver: The account that will receive the ALGO. + :param amount: Amount to send. + :param close_remainder_to: If given, close the sender account and send the remaining balance to this address. + """ + + close_remainder_to: str | None = None + + +@dataclass(frozen=True) +class _RequiredAssetCreateParams(SenderParam): + total: int + + +@dataclass(frozen=True) +class AssetCreateParams(CommonTxnParams, _RequiredAssetCreateParams): + """ + Asset creation parameters. + + :param total: The total amount of the smallest divisible unit to create. + :param decimals: The amount of decimal places the asset should have. + :param default_frozen: Whether the asset is frozen by default in the creator address. + :param manager: The address that can change the manager, reserve, clawback, and freeze addresses. There will permanently be no manager if undefined or an empty string. + :param reserve: The address that holds the uncirculated supply. + :param freeze: The address that can freeze the asset in any account. Freezing will be permanently disabled if undefined or an empty string. + :param clawback: The address that can clawback the asset from any account. Clawback will be permanently disabled if undefined or an empty string. + :param unit_name: The short ticker name for the asset. + :param asset_name: The full name of the asset. + :param url: The metadata URL for the asset. + :param metadata_hash: Hash of the metadata contained in the metadata URL. + """ + + decimals: int | None = None + default_frozen: bool | None = None + manager: str | None = None + reserve: str | None = None + freeze: str | None = None + clawback: str | None = None + unit_name: str | None = None + asset_name: str | None = None + url: str | None = None + metadata_hash: bytes | None = None + + +@dataclass(frozen=True) +class _RequiredAssetConfigParams(SenderParam): + asset_id: int + + +@dataclass(frozen=True) +class AssetConfigParams(CommonTxnParams, _RequiredAssetConfigParams): + """ + Asset configuration parameters. + + :param asset_id: ID of the asset. + :param manager: The address that can change the manager, reserve, clawback, and freeze addresses. There will permanently be no manager if undefined or an empty string. + :param reserve: The address that holds the uncirculated supply. + :param freeze: The address that can freeze the asset in any account. Freezing will be permanently disabled if undefined or an empty string. + :param clawback: The address that can clawback the asset from any account. Clawback will be permanently disabled if undefined or an empty string. + """ + + manager: str | None = None + reserve: str | None = None + freeze: str | None = None + clawback: str | None = None + + +@dataclass(frozen=True) +class _RequiredAssetFreezeParams(SenderParam): + asset_id: int + account: str + frozen: bool + + +@dataclass(frozen=True) +class AssetFreezeParams(CommonTxnParams, _RequiredAssetFreezeParams): + """ + Asset freeze parameters. + + :param asset_id: The ID of the asset. + :param account: The account to freeze or unfreeze. + :param frozen: Whether the assets in the account should be frozen. + """ + + +@dataclass(frozen=True) +class _RequiredAssetDestroyParams(SenderParam): + asset_id: int + + +@dataclass(frozen=True) +class AssetDestroyParams(CommonTxnParams, _RequiredAssetDestroyParams): + """ + Asset destruction parameters. + + :param asset_id: ID of the asset. + """ + + +@dataclass(frozen=True) +class _RequiredOnlineKeyRegParams(SenderParam): + vote_key: str + selection_key: str + vote_first: int + vote_last: int + vote_key_dilution: int + + +@dataclass(frozen=True) +class OnlineKeyRegParams(CommonTxnParams, _RequiredOnlineKeyRegParams): + """ + Online key registration parameters. + + :param vote_key: The root participation public key. + :param selection_key: The VRF public key. + :param vote_first: The first round that the participation key is valid. Not to be confused with the `first_valid` round of the keyreg transaction. + :param vote_last: The last round that the participation key is valid. Not to be confused with the `last_valid` round of the keyreg transaction. + :param vote_key_dilution: This is the dilution for the 2-level participation key. It determines the interval (number of rounds) for generating new ephemeral keys. + :param state_proof_key: The 64 byte state proof public key commitment. + """ + + state_proof_key: bytes | None = None + + +@dataclass(frozen=True) +class _RequiredAssetTransferParams(SenderParam): + asset_id: int + amount: int + receiver: str + + +@dataclass(frozen=True) +class AssetTransferParams(CommonTxnParams, _RequiredAssetTransferParams): + """ + Asset transfer parameters. + + :param asset_id: ID of the asset. + :param amount: Amount of the asset to transfer (smallest divisible unit). + :param receiver: The account to send the asset to. + :param clawback_target: The account to take the asset from. + :param close_asset_to: The account to close the asset to. + """ + + clawback_target: str | None = None + close_asset_to: str | None = None + + +@dataclass(frozen=True) +class _RequiredAssetOptInParams(SenderParam): + asset_id: int + + +@dataclass(frozen=True) +class AssetOptInParams(CommonTxnParams, _RequiredAssetOptInParams): + """ + Asset opt-in parameters. + + :param asset_id: ID of the asset. + """ + + +@dataclass(frozen=True) +class AppCallParams(CommonTxnParams, SenderParam): + """ + Application call parameters. + + :param on_complete: The OnComplete action. + :param app_id: ID of the application. + :param approval_program: The program to execute for all OnCompletes other than ClearState. + :param clear_program: The program to execute for ClearState OnComplete. + :param schema: The state schema for the app. This is immutable. + :param args: Application arguments. + :param account_references: Account references. + :param app_references: App references. + :param asset_references: Asset references. + :param extra_pages: Number of extra pages required for the programs. + :param box_references: Box references. + """ + + on_complete: OnComplete | None = None + app_id: int | None = None + approval_program: bytes | None = None + clear_program: bytes | None = None + schema: dict[str, int] | None = None + args: list[bytes] | None = None + account_references: list[str] | None = None + app_references: list[int] | None = None + asset_references: list[int] | None = None + extra_pages: int | None = None + box_references: list[BoxReference] | None = None + + +@dataclass(frozen=True) +class _RequiredMethodCallParams(SenderParam): + app_id: int + method: Method + + +@dataclass(frozen=True) +class MethodCallParams(CommonTxnParams, _RequiredMethodCallParams): + """ + Method call parameters. + + :param app_id: ID of the application. + :param method: The ABI method to call. + :param args: Arguments to the ABI method. + """ + + args: list | None = None + + +TxnParams = Union[ # noqa: UP007 + PayParams, + AssetCreateParams, + AssetConfigParams, + AssetFreezeParams, + AssetDestroyParams, + OnlineKeyRegParams, + AssetTransferParams, + AssetOptInParams, + AppCallParams, + MethodCallParams, +] + + +class AlgokitComposer: + """ + A class for composing and managing Algorand transactions using the Algosdk library. + + Attributes: + txn_method_map (dict[str, algosdk.abi.Method]): A dictionary that maps transaction IDs to their corresponding ABI methods. + txns (List[Union[TransactionWithSigner, TxnParams, AtomicTransactionComposer]]): A list of transactions that have not yet been composed. + atc (AtomicTransactionComposer): An instance of AtomicTransactionComposer used to compose transactions. + algod (AlgodClient): The AlgodClient instance used by the composer for suggested params. + get_suggested_params (Callable[[], algosdk.future.transaction.SuggestedParams]): A function that returns suggested parameters for transactions. + get_signer (Callable[[str], TransactionSigner]): A function that takes an address as input and returns a TransactionSigner for that address. + default_validity_window (int): The default validity window for transactions. + """ + + def __init__( + self, + algod: AlgodClient, + get_signer: Callable[[str], TransactionSigner], + get_suggested_params: Callable[[], algosdk.transaction.SuggestedParams] | None = None, + default_validity_window: int | None = None, + ): + """ + Initialize an instance of the AlgokitComposer class. + + Args: + algod (AlgodClient): An instance of AlgodClient used to get suggested params and send transactions. + get_signer (Callable[[str], TransactionSigner]): A function that takes an address as input and returns a TransactionSigner for that address. + get_suggested_params (Optional[Callable[[], algosdk.future.transaction.SuggestedParams]], optional): A function that returns suggested parameters for transactions. If not provided, it defaults to using algod.suggested_params(). Defaults to None. + default_validity_window (Optional[int], optional): The default validity window for transactions. If not provided, it defaults to 10. Defaults to None. + """ + self.txn_method_map: dict[str, algosdk.abi.Method] = {} + self.txns: list[TransactionWithSigner | TxnParams | AtomicTransactionComposer] = [] + self.atc: AtomicTransactionComposer = AtomicTransactionComposer() + self.algod: AlgodClient = algod + self.default_get_send_params = lambda: self.algod.suggested_params() + self.get_suggested_params = get_suggested_params or self.default_get_send_params + self.get_signer: Callable[[str], TransactionSigner] = get_signer + self.default_validity_window: int = default_validity_window or 10 + + def add_payment(self, params: PayParams) -> "AlgokitComposer": + self.txns.append(params) + return self + + def add_asset_create(self, params: AssetCreateParams) -> "AlgokitComposer": + self.txns.append(params) + return self + + def add_asset_config(self, params: AssetConfigParams) -> "AlgokitComposer": + self.txns.append(params) + return self + + def add_asset_freeze(self, params: AssetFreezeParams) -> "AlgokitComposer": + self.txns.append(params) + return self + + def add_asset_destroy(self, params: AssetDestroyParams) -> "AlgokitComposer": + self.txns.append(params) + return self + + def add_asset_transfer(self, params: AssetTransferParams) -> "AlgokitComposer": + self.txns.append(params) + return self + + def add_asset_opt_in(self, params: AssetOptInParams) -> "AlgokitComposer": + self.txns.append(params) + return self + + def add_app_call(self, params: AppCallParams) -> "AlgokitComposer": + self.txns.append(params) + return self + + def add_online_key_reg(self, params: OnlineKeyRegParams) -> "AlgokitComposer": + self.txns.append(params) + return self + + def add_atc(self, atc: AtomicTransactionComposer) -> "AlgokitComposer": + self.txns.append(atc) + return self + + def add_method_call(self, params: MethodCallParams) -> "AlgokitComposer": + self.txns.append(params) + return self + + def _build_atc(self, atc: AtomicTransactionComposer) -> list[TransactionWithSigner]: + group = atc.build_group() + + for ts in group: + ts.txn.group = None + + method = atc.method_dict.get(len(group) - 1) + if method: + self.txn_method_map[group[-1].txn.get_txid()] = method # type: ignore[no-untyped-call] + + return group + + def _common_txn_build_step( + self, + params: CommonTxnParams, + txn: algosdk.transaction.Transaction, + suggested_params: algosdk.transaction.SuggestedParams, + ) -> algosdk.transaction.Transaction: + if params.lease: + txn.lease = params.lease + if params.rekey_to: + txn.rekey_to = algosdk.encoding.decode_address(params.rekey_to) # type: ignore[no-untyped-call] + if params.note: + txn.note = params.note + + if params.first_valid_round: + txn.first_valid_round = params.first_valid_round + + if params.last_valid_round: + txn.last_valid_round = params.last_valid_round + else: + txn.last_valid_round = txn.first_valid_round + (params.validity_window or self.default_validity_window) + + if params.static_fee is not None and params.extra_fee is not None: + raise ValueError("Cannot set both static_fee and extra_fee") + + if params.static_fee is not None: + txn.fee = params.static_fee + else: + txn.fee = txn.estimate_size() * suggested_params.fee or algosdk.constants.min_txn_fee # type: ignore[no-untyped-call] + if params.extra_fee: + txn.fee += params.extra_fee + + if params.max_fee is not None and txn.fee > params.max_fee: + raise ValueError(f"Transaction fee {txn.fee} is greater than max_fee {params.max_fee}") + + return txn + + def _build_payment( + self, params: PayParams, suggested_params: algosdk.transaction.SuggestedParams + ) -> algosdk.transaction.Transaction: + txn = algosdk.transaction.PaymentTxn( + sender=params.sender, + sp=suggested_params, + receiver=params.receiver, + amt=params.amount, + close_remainder_to=params.close_remainder_to, + ) # type: ignore[no-untyped-call] + + return self._common_txn_build_step(params, txn, suggested_params) + + def _build_asset_create( + self, params: AssetCreateParams, suggested_params: algosdk.transaction.SuggestedParams + ) -> algosdk.transaction.Transaction: + txn = algosdk.transaction.AssetConfigTxn( + sender=params.sender, + sp=suggested_params, + total=params.total, + default_frozen=params.default_frozen or False, + unit_name=params.unit_name, + asset_name=params.asset_name, + manager=params.manager, + reserve=params.reserve, + freeze=params.freeze, + clawback=params.clawback, + url=params.url, + metadata_hash=params.metadata_hash, + decimals=params.decimals or 0, + strict_empty_address_check=False, + ) # type: ignore[no-untyped-call] + + return self._common_txn_build_step(params, txn, suggested_params) + + def _build_app_call( + self, params: AppCallParams, suggested_params: algosdk.transaction.SuggestedParams + ) -> algosdk.transaction.Transaction: + sdk_params = { + "sender": params.sender, + "sp": suggested_params, + "index": params.app_id or 0, + "on_complete": params.on_complete or algosdk.transaction.OnComplete.NoOpOC, + "approval_program": params.approval_program, + "clear_program": params.clear_program, + "app_args": params.args, + "accounts": params.account_references, + "foreign_apps": params.app_references, + "foreign_assets": params.asset_references, + "extra_pages": params.extra_pages, + "local_schema": algosdk.transaction.StateSchema( + num_uints=params.schema.get("local_uints", 0), num_byte_slices=params.schema.get("local_byte_slices", 0) + ) # type: ignore[no-untyped-call] + if params.schema + else None, + "global_schema": algosdk.transaction.StateSchema( + num_uints=params.schema.get("global_uints", 0), + num_byte_slices=params.schema.get("global_byte_slices", 0), + ) # type: ignore[no-untyped-call] + if params.schema + else None, + } + + if not params.app_id: + if params.approval_program is None or params.clear_program is None: + raise ValueError("approval_program and clear_program are required for application creation") + + txn = algosdk.transaction.ApplicationCreateTxn(**sdk_params) # type: ignore[no-untyped-call] + else: + txn = algosdk.transaction.ApplicationCallTxn(**sdk_params) # type: ignore[assignment,no-untyped-call] + + return self._common_txn_build_step(params, txn, suggested_params) + + def _build_asset_config( + self, params: AssetConfigParams, suggested_params: algosdk.transaction.SuggestedParams + ) -> algosdk.transaction.Transaction: + txn = algosdk.transaction.AssetConfigTxn( + sender=params.sender, + sp=suggested_params, + index=params.asset_id, + manager=params.manager, + reserve=params.reserve, + freeze=params.freeze, + clawback=params.clawback, + strict_empty_address_check=False, + ) # type: ignore[no-untyped-call] + + return self._common_txn_build_step(params, txn, suggested_params) + + def _build_asset_destroy( + self, params: AssetDestroyParams, suggested_params: algosdk.transaction.SuggestedParams + ) -> algosdk.transaction.Transaction: + txn = algosdk.transaction.AssetDestroyTxn( + sender=params.sender, + sp=suggested_params, + index=params.asset_id, + ) # type: ignore[no-untyped-call] + + return self._common_txn_build_step(params, txn, suggested_params) + + def _build_asset_freeze( + self, params: AssetFreezeParams, suggested_params: algosdk.transaction.SuggestedParams + ) -> algosdk.transaction.Transaction: + txn = algosdk.transaction.AssetFreezeTxn( + sender=params.sender, + sp=suggested_params, + index=params.asset_id, + target=params.account, + new_freeze_state=params.frozen, + ) # type: ignore[no-untyped-call] + + return self._common_txn_build_step(params, txn, suggested_params) + + def _build_asset_transfer( + self, params: AssetTransferParams, suggested_params: algosdk.transaction.SuggestedParams + ) -> algosdk.transaction.Transaction: + txn = algosdk.transaction.AssetTransferTxn( + sender=params.sender, + sp=suggested_params, + receiver=params.receiver, + amt=params.amount, + index=params.asset_id, + close_assets_to=params.close_asset_to, + revocation_target=params.clawback_target, + ) # type: ignore[no-untyped-call] + + return self._common_txn_build_step(params, txn, suggested_params) + + def _build_key_reg( + self, params: OnlineKeyRegParams, suggested_params: algosdk.transaction.SuggestedParams + ) -> algosdk.transaction.Transaction: + txn = algosdk.transaction.KeyregTxn( + sender=params.sender, + sp=suggested_params, + votekey=params.vote_key, + selkey=params.selection_key, + votefst=params.vote_first, + votelst=params.vote_last, + votekd=params.vote_key_dilution, + rekey_to=params.rekey_to, + nonpart=False, + sprfkey=params.state_proof_key, + ) # type: ignore[no-untyped-call] + + return self._common_txn_build_step(params, txn, suggested_params) + + def _is_abi_value(self, x: bool | float | str | bytes | list | TxnParams) -> bool: + if isinstance(x, list): + return len(x) == 0 or all(self._is_abi_value(item) for item in x) + + return isinstance(x, bool | int | float | str | bytes) + + def _build_method_call( + self, params: MethodCallParams, suggested_params: algosdk.transaction.SuggestedParams + ) -> list[TransactionWithSigner]: + method_args = [] + arg_offset = 0 + + if params.args: + for i, arg in enumerate(params.args): + if self._is_abi_value(arg): + method_args.append(arg) + continue + + if algosdk.abi.is_abi_transaction_type(params.method.args[i + arg_offset].type): + match arg: + case MethodCallParams(): + temp_txn_with_signers = self._build_method_call(arg, suggested_params) + method_args.extend(temp_txn_with_signers) + arg_offset += len(temp_txn_with_signers) - 1 + continue + case AppCallParams(): + txn = self._build_app_call(arg, suggested_params) + case PayParams(): + txn = self._build_payment(arg, suggested_params) + case AssetOptInParams(): + txn = self._build_asset_transfer( + AssetTransferParams(**arg.__dict__, receiver=arg.sender, amount=0), suggested_params + ) + case AssetCreateParams(): + txn = self._build_asset_create(arg, suggested_params) + case AssetConfigParams(): + txn = self._build_asset_config(arg, suggested_params) + case AssetDestroyParams(): + txn = self._build_asset_destroy(arg, suggested_params) + case AssetFreezeParams(): + txn = self._build_asset_freeze(arg, suggested_params) + case AssetTransferParams(): + txn = self._build_asset_transfer(arg, suggested_params) + case OnlineKeyRegParams(): + txn = self._build_key_reg(arg, suggested_params) + case _: + raise ValueError(f"Unsupported method arg transaction type: {arg}") + + method_args.append( + TransactionWithSigner(txn=txn, signer=params.signer or self.get_signer(params.sender)) + ) + + continue + + raise ValueError(f"Unsupported method arg: {arg}") + + method_atc = AtomicTransactionComposer() + + method_atc.add_method_call( + app_id=params.app_id or 0, + method=params.method, + sender=params.sender, + sp=suggested_params, + signer=params.signer or self.get_signer(params.sender), + method_args=method_args, + on_complete=algosdk.transaction.OnComplete.NoOpOC, + note=params.note, + lease=params.lease, + ) + + return self._build_atc(method_atc) + + def _build_txn( + self, + txn: TransactionWithSigner | TxnParams | AtomicTransactionComposer, + suggested_params: algosdk.transaction.SuggestedParams, + ) -> list[TransactionWithSigner]: + match txn: + case TransactionWithSigner(): + return [txn] + case AtomicTransactionComposer(): + return self._build_atc(txn) + case MethodCallParams(): + return self._build_method_call(txn, suggested_params) + + signer = txn.signer or self.get_signer(txn.sender) + + match txn: + case PayParams(): + payment = self._build_payment(txn, suggested_params) + return [TransactionWithSigner(txn=payment, signer=signer)] + case AssetCreateParams(): + asset_create = self._build_asset_create(txn, suggested_params) + return [TransactionWithSigner(txn=asset_create, signer=signer)] + case AppCallParams(): + app_call = self._build_app_call(txn, suggested_params) + return [TransactionWithSigner(txn=app_call, signer=signer)] + case AssetConfigParams(): + asset_config = self._build_asset_config(txn, suggested_params) + return [TransactionWithSigner(txn=asset_config, signer=signer)] + case AssetDestroyParams(): + asset_destroy = self._build_asset_destroy(txn, suggested_params) + return [TransactionWithSigner(txn=asset_destroy, signer=signer)] + case AssetFreezeParams(): + asset_freeze = self._build_asset_freeze(txn, suggested_params) + return [TransactionWithSigner(txn=asset_freeze, signer=signer)] + case AssetTransferParams(): + asset_transfer = self._build_asset_transfer(txn, suggested_params) + return [TransactionWithSigner(txn=asset_transfer, signer=signer)] + case AssetOptInParams(): + asset_transfer = self._build_asset_transfer( + AssetTransferParams(**txn.__dict__, receiver=txn.sender, amount=0), suggested_params + ) + return [TransactionWithSigner(txn=asset_transfer, signer=signer)] + case OnlineKeyRegParams(): + key_reg = self._build_key_reg(txn, suggested_params) + return [TransactionWithSigner(txn=key_reg, signer=signer)] + case _: + raise ValueError(f"Unsupported txn: {txn}") + + def build_group(self) -> list[TransactionWithSigner]: + suggested_params = self.get_suggested_params() + + txn_with_signers: list[TransactionWithSigner] = [] + + for txn in self.txns: + txn_with_signers.extend(self._build_txn(txn, suggested_params)) + + for ts in txn_with_signers: + self.atc.add_transaction(ts) + + method_calls = {} + + for i, ts in enumerate(txn_with_signers): + method = self.txn_method_map.get(ts.txn.get_txid()) # type: ignore[no-untyped-call] + if method: + method_calls[i] = method + + self.atc.method_dict = method_calls + + return self.atc.build_group() + + def execute(self, *, max_rounds_to_wait: int | None = None) -> AtomicTransactionResponse: + group = self.build_group() + + wait_rounds = max_rounds_to_wait + + if wait_rounds is None: + last_round = max(txn.txn.last_valid_round for txn in group) + first_round = self.get_suggested_params().first + wait_rounds = last_round - first_round + + return self.atc.execute(client=self.algod, wait_rounds=wait_rounds) diff --git a/src/algokit_utils/network_clients.py b/src/algokit_utils/network_clients.py index a0b55e69..7129be7c 100644 --- a/src/algokit_utils/network_clients.py +++ b/src/algokit_utils/network_clients.py @@ -18,6 +18,8 @@ "is_localnet", "is_mainnet", "is_testnet", + "AlgoClientConfigs", + "get_kmd_client", ] _PURE_STAKE_HOST = "purestake.io" @@ -34,6 +36,13 @@ class AlgoClientConfig: """API Token to authenticate with the service""" +@dataclasses.dataclass +class AlgoClientConfigs: + algod_config: AlgoClientConfig + indexer_config: AlgoClientConfig + kmd_config: AlgoClientConfig | None + + def get_default_localnet_config(config: Literal["algod", "indexer", "kmd"]) -> AlgoClientConfig: """Returns the client configuration to point to the default LocalNet""" port = {"algod": 4001, "indexer": 8980, "kmd": 4002}[config] @@ -69,6 +78,14 @@ def get_algod_client(config: AlgoClientConfig | None = None) -> AlgodClient: return AlgodClient(config.token, config.server, headers) +def get_kmd_client(config: AlgoClientConfig | None = None) -> KMDClient: + """Returns an {py:class}`algosdk.kmd.KMDClient` from `config` or environment + + If no configuration provided will use environment variables `KMD_SERVER`, `KMD_PORT` and `KMD_TOKEN`""" + config = config or _get_config_from_environment("KMD") + return KMDClient(config.token, config.server) # type: ignore[no-untyped-call] + + def get_indexer_client(config: AlgoClientConfig | None = None) -> IndexerClient: """Returns an {py:class}`algosdk.v2client.indexer.IndexerClient` from `config` or environment. diff --git a/tests/app_algorand_client.json b/tests/app_algorand_client.json new file mode 100644 index 00000000..de1411cc --- /dev/null +++ b/tests/app_algorand_client.json @@ -0,0 +1,179 @@ +{ + "hints": { + "doMath(uint64,uint64,string)uint64": { + "call_config": { + "no_op": "CALL" + } + }, + "txnArg(pay)address": { + "call_config": { + "no_op": "CALL" + } + }, + "helloWorld()string": { + "call_config": { + "no_op": "CALL" + } + }, + "methodArg(appl)uint64": { + "call_config": { + "no_op": "CALL" + } + }, + "nestedTxnArg(pay,appl)uint64": { + "call_config": { + "no_op": "CALL" + } + }, + "doubleNestedTxnArg(pay,appl,pay,appl)uint64": { + "call_config": { + "no_op": "CALL" + } + }, + "createApplication()void": { + "call_config": { + "no_op": "CREATE" + } + } + }, + "bare_call_config": { + "no_op": "NEVER", + "opt_in": "NEVER", + "close_out": "NEVER", + "update_application": "NEVER", + "delete_application": "NEVER" + }, + "schema": { + "local": { + "declared": {}, + "reserved": {} + }, + "global": { + "declared": {}, + "reserved": {} + } + }, + "state": { + "global": { + "num_byte_slices": 0, + "num_uints": 0 + }, + "local": { + "num_byte_slices": 0, + "num_uints": 0 + } + }, + "source": { + "approval": "I3ByYWdtYSB2ZXJzaW9uIDEwCgovLyBUaGlzIFRFQUwgd2FzIGdlbmVyYXRlZCBieSBURUFMU2NyaXB0IHYwLjg2LjAKLy8gaHR0cHM6Ly9naXRodWIuY29tL2FsZ29yYW5kZm91bmRhdGlvbi9URUFMU2NyaXB0CgovLyBUaGlzIGNvbnRyYWN0IGlzIGNvbXBsaWFudCB3aXRoIGFuZC9vciBpbXBsZW1lbnRzIHRoZSBmb2xsb3dpbmcgQVJDczogWyBBUkM0IF0KCi8vIFRoZSBmb2xsb3dpbmcgdGVuIGxpbmVzIG9mIFRFQUwgaGFuZGxlIGluaXRpYWwgcHJvZ3JhbSBmbG93Ci8vIFRoaXMgcGF0dGVybiBpcyB1c2VkIHRvIG1ha2UgaXQgZWFzeSBmb3IgYW55b25lIHRvIHBhcnNlIHRoZSBzdGFydCBvZiB0aGUgcHJvZ3JhbSBhbmQgZGV0ZXJtaW5lIGlmIGEgc3BlY2lmaWMgYWN0aW9uIGlzIGFsbG93ZWQKLy8gSGVyZSwgYWN0aW9uIHJlZmVycyB0byB0aGUgT25Db21wbGV0ZSBpbiBjb21iaW5hdGlvbiB3aXRoIHdoZXRoZXIgdGhlIGFwcCBpcyBiZWluZyBjcmVhdGVkIG9yIGNhbGxlZAovLyBFdmVyeSBwb3NzaWJsZSBhY3Rpb24gZm9yIHRoaXMgY29udHJhY3QgaXMgcmVwcmVzZW50ZWQgaW4gdGhlIHN3aXRjaCBzdGF0ZW1lbnQKLy8gSWYgdGhlIGFjdGlvbiBpcyBub3QgaW1wbGVtZW50ZWQgaW4gdGhlIGNvbnRyYWN0LCBpdHMgcmVzcGVjdGl2ZSBicmFuY2ggd2lsbCBiZSAiKk5PVF9JTVBMRU1FTlRFRCIgd2hpY2gganVzdCBjb250YWlucyAiZXJyIgp0eG4gQXBwbGljYXRpb25JRAohCmludCA2CioKdHhuIE9uQ29tcGxldGlvbgorCnN3aXRjaCAqY2FsbF9Ob09wICpOT1RfSU1QTEVNRU5URUQgKk5PVF9JTVBMRU1FTlRFRCAqTk9UX0lNUExFTUVOVEVEICpOT1RfSU1QTEVNRU5URUQgKk5PVF9JTVBMRU1FTlRFRCAqY3JlYXRlX05vT3AgKk5PVF9JTVBMRU1FTlRFRCAqTk9UX0lNUExFTUVOVEVEICpOT1RfSU1QTEVNRU5URUQgKk5PVF9JTVBMRU1FTlRFRCAqTk9UX0lNUExFTUVOVEVECgoqTk9UX0lNUExFTUVOVEVEOgoJZXJyCgovLyBnZXRTdW0oYTogbnVtYmVyLCBiOiBudW1iZXIpOiBudW1iZXIKLy8KLy8gQ2FsY3VsYXRlcyB0aGUgc3VtIG9mIHR3byBudW1iZXJzCi8vCi8vIEBwYXJhbSBhCi8vIEBwYXJhbSBiCi8vIEByZXR1cm5zIFRoZSBzdW0gb2YgYSBhbmQgYgpnZXRTdW06Cglwcm90byAyIDEKCgkvLyB0ZXN0X2NvbnRyYWN0L3Rlc3QuYWxnby50czoxMgoJLy8gcmV0dXJuIGEgKyBiOwoJZnJhbWVfZGlnIC0xIC8vIGE6IG51bWJlcgoJZnJhbWVfZGlnIC0yIC8vIGI6IG51bWJlcgoJKwoJcmV0c3ViCgovLyBnZXREaWZmZXJlbmNlKGE6IG51bWJlciwgYjogbnVtYmVyKTogbnVtYmVyCi8vCi8vIENhbGN1bGF0ZXMgdGhlIGRpZmZlcmVuY2UgYmV0d2VlbiB0d28gbnVtYmVycwovLwovLyBAcGFyYW0gYQovLyBAcGFyYW0gYgovLyBAcmV0dXJucyBUaGUgZGlmZmVyZW5jZSBiZXR3ZWVuIGEgYW5kIGIuCmdldERpZmZlcmVuY2U6Cglwcm90byAyIDEKCgkvLyB0ZXN0X2NvbnRyYWN0L3Rlc3QuYWxnby50czoyMwoJLy8gcmV0dXJuIGEgPj0gYiA/IGEgLSBiIDogYiAtIGE7CglmcmFtZV9kaWcgLTEgLy8gYTogbnVtYmVyCglmcmFtZV9kaWcgLTIgLy8gYjogbnVtYmVyCgk+PQoJYnogKnRlcm5hcnkwX2ZhbHNlCglmcmFtZV9kaWcgLTEgLy8gYTogbnVtYmVyCglmcmFtZV9kaWcgLTIgLy8gYjogbnVtYmVyCgktCgliICp0ZXJuYXJ5MF9lbmQKCip0ZXJuYXJ5MF9mYWxzZToKCWZyYW1lX2RpZyAtMiAvLyBiOiBudW1iZXIKCWZyYW1lX2RpZyAtMSAvLyBhOiBudW1iZXIKCS0KCip0ZXJuYXJ5MF9lbmQ6CglyZXRzdWIKCi8vIGRvTWF0aCh1aW50NjQsdWludDY0LHN0cmluZyl1aW50NjQKKmFiaV9yb3V0ZV9kb01hdGg6CgkvLyBUaGUgQUJJIHJldHVybiBwcmVmaXgKCWJ5dGUgMHgxNTFmN2M3NQoKCS8vIG9wZXJhdGlvbjogc3RyaW5nCgl0eG5hIEFwcGxpY2F0aW9uQXJncyAzCglleHRyYWN0IDIgMAoKCS8vIGI6IHVpbnQ2NAoJdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMgoJYnRvaQoKCS8vIGE6IHVpbnQ2NAoJdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMQoJYnRvaQoKCS8vIGV4ZWN1dGUgZG9NYXRoKHVpbnQ2NCx1aW50NjQsc3RyaW5nKXVpbnQ2NAoJY2FsbHN1YiBkb01hdGgKCWl0b2IKCWNvbmNhdAoJbG9nCglpbnQgMQoJcmV0dXJuCgovLyBkb01hdGgoYTogbnVtYmVyLCBiOiBudW1iZXIsIG9wZXJhdGlvbjogc3RyaW5nKTogbnVtYmVyCi8vCi8vIEEgbWV0aG9kIHRoYXQgdGFrZXMgdHdvIG51bWJlcnMgYW5kIGRvZXMgZWl0aGVyIGFkZGl0aW9uIG9yIHN1YnRyYWN0aW9uCi8vCi8vIEBwYXJhbSBhIFRoZSBmaXJzdCBudW1iZXIKLy8gQHBhcmFtIGIgVGhlIHNlY29uZCBudW1iZXIKLy8gQHBhcmFtIG9wZXJhdGlvbiBUaGUgb3BlcmF0aW9uIHRvIHBlcmZvcm0uIENhbiBiZSBlaXRoZXIgJ3N1bScgb3IgJ2RpZmZlcmVuY2UnCi8vCi8vIEByZXR1cm5zIFRoZSByZXN1bHQgb2YgdGhlIG9wZXJhdGlvbgpkb01hdGg6Cglwcm90byAzIDEKCgkvLyBQdXNoIGVtcHR5IGJ5dGVzIGFmdGVyIHRoZSBmcmFtZSBwb2ludGVyIHRvIHJlc2VydmUgc3BhY2UgZm9yIGxvY2FsIHZhcmlhYmxlcwoJYnl0ZSAweAoKCS8vICppZjBfY29uZGl0aW9uCgkvLyB0ZXN0X2NvbnRyYWN0L3Rlc3QuYWxnby50czozOAoJLy8gb3BlcmF0aW9uID09PSAnc3VtJwoJZnJhbWVfZGlnIC0zIC8vIG9wZXJhdGlvbjogc3RyaW5nCglieXRlIDB4NzM3NTZkIC8vICJzdW0iCgk9PQoJYnogKmlmMF9lbHNlaWYxX2NvbmRpdGlvbgoKCS8vICppZjBfY29uc2VxdWVudAoJLy8gdGVzdF9jb250cmFjdC90ZXN0LmFsZ28udHM6MzkKCS8vIHJlc3VsdCA9IHRoaXMuZ2V0U3VtKGEsIGIpCglmcmFtZV9kaWcgLTIgLy8gYjogbnVtYmVyCglmcmFtZV9kaWcgLTEgLy8gYTogbnVtYmVyCgljYWxsc3ViIGdldFN1bQoJZnJhbWVfYnVyeSAwIC8vIHJlc3VsdDogbnVtYmVyCgliICppZjBfZW5kCgoqaWYwX2Vsc2VpZjFfY29uZGl0aW9uOgoJLy8gdGVzdF9jb250cmFjdC90ZXN0LmFsZ28udHM6NDAKCS8vIG9wZXJhdGlvbiA9PT0gJ2RpZmZlcmVuY2UnCglmcmFtZV9kaWcgLTMgLy8gb3BlcmF0aW9uOiBzdHJpbmcKCWJ5dGUgMHg2NDY5NjY2NjY1NzI2NTZlNjM2NSAvLyAiZGlmZmVyZW5jZSIKCT09CglieiAqaWYwX2Vsc2UKCgkvLyAqaWYwX2Vsc2VpZjFfY29uc2VxdWVudAoJLy8gdGVzdF9jb250cmFjdC90ZXN0LmFsZ28udHM6NDEKCS8vIHJlc3VsdCA9IHRoaXMuZ2V0RGlmZmVyZW5jZShhLCBiKQoJZnJhbWVfZGlnIC0yIC8vIGI6IG51bWJlcgoJZnJhbWVfZGlnIC0xIC8vIGE6IG51bWJlcgoJY2FsbHN1YiBnZXREaWZmZXJlbmNlCglmcmFtZV9idXJ5IDAgLy8gcmVzdWx0OiBudW1iZXIKCWIgKmlmMF9lbmQKCippZjBfZWxzZToKCWVyciAvLyAnSW52YWxpZCBvcGVyYXRpb24nCgoqaWYwX2VuZDoKCS8vIHRlc3RfY29udHJhY3QvdGVzdC5hbGdvLnRzOjQ0CgkvLyByZXR1cm4gcmVzdWx0OwoJZnJhbWVfZGlnIDAgLy8gcmVzdWx0OiBudW1iZXIKCgkvLyBzZXQgdGhlIHN1YnJvdXRpbmUgcmV0dXJuIHZhbHVlCglmcmFtZV9idXJ5IDAKCXJldHN1YgoKLy8gdHhuQXJnKHBheSlhZGRyZXNzCiphYmlfcm91dGVfdHhuQXJnOgoJLy8gVGhlIEFCSSByZXR1cm4gcHJlZml4CglieXRlIDB4MTUxZjdjNzUKCgkvLyB0eG46IHBheQoJdHhuIEdyb3VwSW5kZXgKCWludCAxCgktCglkdXAKCWd0eG5zIFR5cGVFbnVtCglpbnQgcGF5Cgk9PQoJYXNzZXJ0CgoJLy8gZXhlY3V0ZSB0eG5BcmcocGF5KWFkZHJlc3MKCWNhbGxzdWIgdHhuQXJnCgljb25jYXQKCWxvZwoJaW50IDEKCXJldHVybgoKLy8gdHhuQXJnKHR4bjogUGF5VHhuKTogQWRkcmVzcwp0eG5Bcmc6Cglwcm90byAxIDEKCgkvLyB0ZXN0X2NvbnRyYWN0L3Rlc3QuYWxnby50czo0OAoJLy8gcmV0dXJuIHR4bi5zZW5kZXI7CglmcmFtZV9kaWcgLTEgLy8gdHhuOiBQYXlUeG4KCWd0eG5zIFNlbmRlcgoJcmV0c3ViCgovLyBoZWxsb1dvcmxkKClzdHJpbmcKKmFiaV9yb3V0ZV9oZWxsb1dvcmxkOgoJLy8gVGhlIEFCSSByZXR1cm4gcHJlZml4CglieXRlIDB4MTUxZjdjNzUKCgkvLyBleGVjdXRlIGhlbGxvV29ybGQoKXN0cmluZwoJY2FsbHN1YiBoZWxsb1dvcmxkCglkdXAKCWxlbgoJaXRvYgoJZXh0cmFjdCA2IDIKCXN3YXAKCWNvbmNhdAoJY29uY2F0Cglsb2cKCWludCAxCglyZXR1cm4KCi8vIGhlbGxvV29ybGQoKTogc3RyaW5nCmhlbGxvV29ybGQ6Cglwcm90byAwIDEKCgkvLyB0ZXN0X2NvbnRyYWN0L3Rlc3QuYWxnby50czo1MgoJLy8gcmV0dXJuICdIZWxsbywgV29ybGQhJzsKCWJ5dGUgMHg0ODY1NmM2YzZmMmMyMDU3NmY3MjZjNjQyMSAvLyAiSGVsbG8sIFdvcmxkISIKCXJldHN1YgoKLy8gbWV0aG9kQXJnKGFwcGwpdWludDY0CiphYmlfcm91dGVfbWV0aG9kQXJnOgoJLy8gVGhlIEFCSSByZXR1cm4gcHJlZml4CglieXRlIDB4MTUxZjdjNzUKCgkvLyBjYWxsOiBhcHBsCgl0eG4gR3JvdXBJbmRleAoJaW50IDEKCS0KCWR1cAoJZ3R4bnMgVHlwZUVudW0KCWludCBhcHBsCgk9PQoJYXNzZXJ0CgoJLy8gZXhlY3V0ZSBtZXRob2RBcmcoYXBwbCl1aW50NjQKCWNhbGxzdWIgbWV0aG9kQXJnCglpdG9iCgljb25jYXQKCWxvZwoJaW50IDEKCXJldHVybgoKLy8gbWV0aG9kQXJnKGNhbGw6IEFwcENhbGxUeG4pOiBBcHBJRAptZXRob2RBcmc6Cglwcm90byAxIDEKCgkvLyB0ZXN0X2NvbnRyYWN0L3Rlc3QuYWxnby50czo1NgoJLy8gcmV0dXJuIGNhbGwuYXBwbGljYXRpb25JRAoJZnJhbWVfZGlnIC0xIC8vIGNhbGw6IEFwcENhbGxUeG4KCWd0eG5zIEFwcGxpY2F0aW9uSUQKCXJldHN1YgoKLy8gbmVzdGVkVHhuQXJnKHBheSxhcHBsKXVpbnQ2NAoqYWJpX3JvdXRlX25lc3RlZFR4bkFyZzoKCS8vIFRoZSBBQkkgcmV0dXJuIHByZWZpeAoJYnl0ZSAweDE1MWY3Yzc1CgoJLy8gY2FsbDogYXBwbAoJdHhuIEdyb3VwSW5kZXgKCWludCAxCgktCglkdXAKCWd0eG5zIFR5cGVFbnVtCglpbnQgYXBwbAoJPT0KCWFzc2VydAoKCS8vIHR4bjogcGF5Cgl0eG4gR3JvdXBJbmRleAoJaW50IDIKCS0KCWR1cAoJZ3R4bnMgVHlwZUVudW0KCWludCBwYXkKCT09Cglhc3NlcnQKCgkvLyBleGVjdXRlIG5lc3RlZFR4bkFyZyhwYXksYXBwbCl1aW50NjQKCWNhbGxzdWIgbmVzdGVkVHhuQXJnCglpdG9iCgljb25jYXQKCWxvZwoJaW50IDEKCXJldHVybgoKLy8gbmVzdGVkVHhuQXJnKHR4bjogUGF5VHhuLCBjYWxsOiBBcHBDYWxsVHhuKTogQXBwSUQKbmVzdGVkVHhuQXJnOgoJcHJvdG8gMiAxCgoJLy8gdGVzdF9jb250cmFjdC90ZXN0LmFsZ28udHM6NjAKCS8vIHJldHVybiBjYWxsLmFwcGxpY2F0aW9uSUQKCWZyYW1lX2RpZyAtMiAvLyBjYWxsOiBBcHBDYWxsVHhuCglndHhucyBBcHBsaWNhdGlvbklECglyZXRzdWIKCi8vIGRvdWJsZU5lc3RlZFR4bkFyZyhwYXksYXBwbCxwYXksYXBwbCl1aW50NjQKKmFiaV9yb3V0ZV9kb3VibGVOZXN0ZWRUeG5Bcmc6CgkvLyBUaGUgQUJJIHJldHVybiBwcmVmaXgKCWJ5dGUgMHgxNTFmN2M3NQoKCS8vIGNhbGwzOiBhcHBsCgl0eG4gR3JvdXBJbmRleAoJaW50IDEKCS0KCWR1cAoJZ3R4bnMgVHlwZUVudW0KCWludCBhcHBsCgk9PQoJYXNzZXJ0CgoJLy8gdHhuMjogcGF5Cgl0eG4gR3JvdXBJbmRleAoJaW50IDIKCS0KCWR1cAoJZ3R4bnMgVHlwZUVudW0KCWludCBwYXkKCT09Cglhc3NlcnQKCgkvLyBjYWxsMTogYXBwbAoJdHhuIEdyb3VwSW5kZXgKCWludCAzCgktCglkdXAKCWd0eG5zIFR5cGVFbnVtCglpbnQgYXBwbAoJPT0KCWFzc2VydAoKCS8vIHR4bjA6IHBheQoJdHhuIEdyb3VwSW5kZXgKCWludCA0CgktCglkdXAKCWd0eG5zIFR5cGVFbnVtCglpbnQgcGF5Cgk9PQoJYXNzZXJ0CgoJLy8gZXhlY3V0ZSBkb3VibGVOZXN0ZWRUeG5BcmcocGF5LGFwcGwscGF5LGFwcGwpdWludDY0CgljYWxsc3ViIGRvdWJsZU5lc3RlZFR4bkFyZwoJaXRvYgoJY29uY2F0Cglsb2cKCWludCAxCglyZXR1cm4KCi8vIGRvdWJsZU5lc3RlZFR4bkFyZyh0eG4wOiBQYXlUeG4sIGNhbGwxOiBBcHBDYWxsVHhuLCB0eG4yOiBQYXlUeG4sIGNhbGwzOiBBcHBDYWxsVHhuKTogQXBwSUQKZG91YmxlTmVzdGVkVHhuQXJnOgoJcHJvdG8gNCAxCgoJLy8gdGVzdF9jb250cmFjdC90ZXN0LmFsZ28udHM6NjQKCS8vIHJldHVybiBjYWxsMS5hcHBsaWNhdGlvbklECglmcmFtZV9kaWcgLTIgLy8gY2FsbDE6IEFwcENhbGxUeG4KCWd0eG5zIEFwcGxpY2F0aW9uSUQKCXJldHN1YgoKKmFiaV9yb3V0ZV9jcmVhdGVBcHBsaWNhdGlvbjoKCWludCAxCglyZXR1cm4KCipjcmVhdGVfTm9PcDoKCW1ldGhvZCAiY3JlYXRlQXBwbGljYXRpb24oKXZvaWQiCgl0eG5hIEFwcGxpY2F0aW9uQXJncyAwCgltYXRjaCAqYWJpX3JvdXRlX2NyZWF0ZUFwcGxpY2F0aW9uCgllcnIKCipjYWxsX05vT3A6CgltZXRob2QgImRvTWF0aCh1aW50NjQsdWludDY0LHN0cmluZyl1aW50NjQiCgltZXRob2QgInR4bkFyZyhwYXkpYWRkcmVzcyIKCW1ldGhvZCAiaGVsbG9Xb3JsZCgpc3RyaW5nIgoJbWV0aG9kICJtZXRob2RBcmcoYXBwbCl1aW50NjQiCgltZXRob2QgIm5lc3RlZFR4bkFyZyhwYXksYXBwbCl1aW50NjQiCgltZXRob2QgImRvdWJsZU5lc3RlZFR4bkFyZyhwYXksYXBwbCxwYXksYXBwbCl1aW50NjQiCgl0eG5hIEFwcGxpY2F0aW9uQXJncyAwCgltYXRjaCAqYWJpX3JvdXRlX2RvTWF0aCAqYWJpX3JvdXRlX3R4bkFyZyAqYWJpX3JvdXRlX2hlbGxvV29ybGQgKmFiaV9yb3V0ZV9tZXRob2RBcmcgKmFiaV9yb3V0ZV9uZXN0ZWRUeG5BcmcgKmFiaV9yb3V0ZV9kb3VibGVOZXN0ZWRUeG5BcmcKCWVycg==", + "clear": "I3ByYWdtYSB2ZXJzaW9uIDEw" + }, + "contract": { + "name": "TestContract", + "desc": "", + "methods": [ + { + "name": "doMath", + "desc": "A method that takes two numbers and does either addition or subtraction", + "args": [ + { + "name": "a", + "type": "uint64", + "desc": "The first number" + }, + { + "name": "b", + "type": "uint64", + "desc": "The second number" + }, + { + "name": "operation", + "type": "string", + "desc": "The operation to perform. Can be either 'sum' or 'difference'" + } + ], + "returns": { + "type": "uint64", + "desc": "The result of the operation" + } + }, + { + "name": "txnArg", + "args": [ + { + "name": "txn", + "type": "pay" + } + ], + "returns": { + "type": "address" + } + }, + { + "name": "helloWorld", + "args": [], + "returns": { + "type": "string" + } + }, + { + "name": "methodArg", + "args": [ + { + "name": "call", + "type": "appl" + } + ], + "returns": { + "type": "uint64" + } + }, + { + "name": "nestedTxnArg", + "args": [ + { + "name": "txn", + "type": "pay" + }, + { + "name": "call", + "type": "appl" + } + ], + "returns": { + "type": "uint64" + } + }, + { + "name": "doubleNestedTxnArg", + "args": [ + { + "name": "txn0", + "type": "pay" + }, + { + "name": "call1", + "type": "appl" + }, + { + "name": "txn2", + "type": "pay" + }, + { + "name": "call3", + "type": "appl" + } + ], + "returns": { + "type": "uint64" + } + }, + { + "name": "createApplication", + "args": [], + "returns": { + "type": "void" + } + } + ] + } +} \ No newline at end of file diff --git a/tests/test_algorand_client.py b/tests/test_algorand_client.py new file mode 100644 index 00000000..5f258640 --- /dev/null +++ b/tests/test_algorand_client.py @@ -0,0 +1,222 @@ +import json +from pathlib import Path + +import pytest +from algokit_utils import Account, ApplicationClient +from algokit_utils.beta.account_manager import AddressAndSigner +from algokit_utils.beta.algorand_client import ( + AlgorandClient, + AssetCreateParams, + AssetOptInParams, + MethodCallParams, + PayParams, +) +from algosdk.abi import Contract +from algosdk.atomic_transaction_composer import AtomicTransactionComposer + + +@pytest.fixture() +def algorand(funded_account: Account) -> AlgorandClient: + client = AlgorandClient.default_local_net() + client.set_signer(sender=funded_account.address, signer=funded_account.signer) + return client + + +@pytest.fixture() +def alice(algorand: AlgorandClient, funded_account: Account) -> AddressAndSigner: + acct = algorand.account.random() + algorand.send.payment(PayParams(sender=funded_account.address, receiver=acct.address, amount=1_000_000)) + return acct + + +@pytest.fixture() +def bob(algorand: AlgorandClient, funded_account: Account) -> AddressAndSigner: + acct = algorand.account.random() + algorand.send.payment(PayParams(sender=funded_account.address, receiver=acct.address, amount=1_000_000)) + return acct + + +@pytest.fixture() +def app_client(algorand: AlgorandClient, alice: AddressAndSigner) -> ApplicationClient: + client = ApplicationClient( + algorand.client.algod, + Path(__file__).parent / "app_algorand_client.json", + sender=alice.address, + signer=alice.signer, + ) + client.create(call_abi_method="createApplication") + return client + + +@pytest.fixture() +def contract() -> Contract: + with Path.open(Path(__file__).parent / "app_algorand_client.json") as f: + return Contract.from_json(json.dumps(json.load(f)["contract"])) + + +def test_send_payment(algorand: AlgorandClient, alice: AddressAndSigner, bob: AddressAndSigner) -> None: + amount = 100_000 + + alice_pre_balance = algorand.account.get_information(alice.address)["amount"] + bob_pre_balance = algorand.account.get_information(bob.address)["amount"] + result = algorand.send.payment(PayParams(sender=alice.address, receiver=bob.address, amount=amount)) + alice_post_balance = algorand.account.get_information(alice.address)["amount"] + bob_post_balance = algorand.account.get_information(bob.address)["amount"] + + assert result["confirmation"] is not None + assert alice_post_balance == alice_pre_balance - 1000 - amount + assert bob_post_balance == bob_pre_balance + amount + + +def test_send_asset_create(algorand: AlgorandClient, alice: AddressAndSigner) -> None: + total = 100 + + result = algorand.send.asset_create(AssetCreateParams(sender=alice.address, total=total)) + asset_index = result["confirmation"]["asset-index"] + + assert asset_index > 0 + + +def test_asset_opt_in(algorand: AlgorandClient, alice: AddressAndSigner, bob: AddressAndSigner) -> None: + total = 100 + + result = algorand.send.asset_create(AssetCreateParams(sender=alice.address, total=total)) + asset_index = result["confirmation"]["asset-index"] + + algorand.send.asset_opt_in(AssetOptInParams(sender=bob.address, asset_id=asset_index)) + + assert algorand.account.get_asset_information(bob.address, asset_index) is not None + + +DO_MATH_VALUE = 3 + + +def test_add_atc(algorand: AlgorandClient, app_client: ApplicationClient, alice: AddressAndSigner) -> None: + atc = AtomicTransactionComposer() + app_client.compose_call(atc, call_abi_method="doMath", a=1, b=2, operation="sum") + + result = ( + algorand.new_group() + .add_payment(PayParams(sender=alice.address, amount=0, receiver=alice.address)) + .add_atc(atc) + .execute() + ) + assert result.abi_results[0].return_value == DO_MATH_VALUE + + +def test_add_method_call( + algorand: AlgorandClient, contract: Contract, alice: AddressAndSigner, app_client: ApplicationClient +) -> None: + result = ( + algorand.new_group() + .add_payment(PayParams(sender=alice.address, amount=0, receiver=alice.address)) + .add_method_call( + MethodCallParams( + method=contract.get_method_by_name("doMath"), + sender=alice.address, + app_id=app_client.app_id, + args=[1, 2, "sum"], + ) + ) + .execute() + ) + assert result.abi_results[0].return_value == DO_MATH_VALUE + + +def test_add_method_with_txn_arg( + algorand: AlgorandClient, contract: Contract, alice: AddressAndSigner, app_client: ApplicationClient +) -> None: + pay_arg = PayParams(sender=alice.address, receiver=alice.address, amount=1) + result = ( + algorand.new_group() + .add_payment(PayParams(sender=alice.address, amount=0, receiver=alice.address)) + .add_method_call( + MethodCallParams( + method=contract.get_method_by_name("txnArg"), + sender=alice.address, + app_id=app_client.app_id, + args=[pay_arg], + ) + ) + .execute() + ) + assert result.abi_results[0].return_value == alice.address + + +def test_add_method_call_with_method_call_arg( + algorand: AlgorandClient, contract: Contract, alice: AddressAndSigner, app_client: ApplicationClient +) -> None: + hello_world_call = MethodCallParams( + method=contract.get_method_by_name("helloWorld"), sender=alice.address, app_id=app_client.app_id + ) + result = ( + algorand.new_group() + .add_method_call( + MethodCallParams( + method=contract.get_method_by_name("methodArg"), + sender=alice.address, + app_id=app_client.app_id, + args=[hello_world_call], + ) + ) + .execute() + ) + assert result.abi_results[0].return_value == "Hello, World!" + assert result.abi_results[1].return_value == app_client.app_id + + +def test_add_method_call_with_method_call_arg_with_txn_arg( + algorand: AlgorandClient, contract: Contract, alice: AddressAndSigner, app_client: ApplicationClient +) -> None: + pay_arg = PayParams(sender=alice.address, receiver=alice.address, amount=1) + txn_arg_call = MethodCallParams( + method=contract.get_method_by_name("txnArg"), sender=alice.address, app_id=app_client.app_id, args=[pay_arg] + ) + result = ( + algorand.new_group() + .add_method_call( + MethodCallParams( + method=contract.get_method_by_name("nestedTxnArg"), + sender=alice.address, + app_id=app_client.app_id, + args=[txn_arg_call], + ) + ) + .execute() + ) + assert result.abi_results[0].return_value == alice.address + assert result.abi_results[1].return_value == app_client.app_id + + +def test_add_method_call_with_two_method_call_args_with_txn_arg( + algorand: AlgorandClient, contract: Contract, alice: AddressAndSigner, app_client: ApplicationClient +) -> None: + pay_arg_1 = PayParams(sender=alice.address, receiver=alice.address, amount=1) + txn_arg_call_1 = MethodCallParams( + method=contract.get_method_by_name("txnArg"), + sender=alice.address, + app_id=app_client.app_id, + args=[pay_arg_1], + note=b"1", + ) + + pay_arg_2 = PayParams(sender=alice.address, receiver=alice.address, amount=2) + txn_arg_call_2 = MethodCallParams( + method=contract.get_method_by_name("txnArg"), sender=alice.address, app_id=app_client.app_id, args=[pay_arg_2] + ) + + result = ( + algorand.new_group() + .add_method_call( + MethodCallParams( + method=contract.get_method_by_name("doubleNestedTxnArg"), + sender=alice.address, + app_id=app_client.app_id, + args=[txn_arg_call_1, txn_arg_call_2], + ) + ) + .execute() + ) + assert result.abi_results[0].return_value == alice.address + assert result.abi_results[1].return_value == alice.address + assert result.abi_results[2].return_value == app_client.app_id