From 1c77ad87c137e38707c6d71510594417c82057f6 Mon Sep 17 00:00:00 2001 From: Al Date: Mon, 4 Nov 2024 17:16:53 +0100 Subject: [PATCH] feat: TransactionComposer & AppManager implementation; various ongoing refactoring efforts (#120) * feat: Initial AppManager implementation; wip on Composer; mypy tweaks; initial tests - Add AppManager class with methods for compiling TEAL, managing app state, and handling template variables - Update TransactionComposer to use AppManager - Move Account model to a separate file and update imports - Add new models for ABI values and application constants - Improve type annotations and remove unnecessary type ignores - Add initial tests for AppManager template substitution and comment stripping - Update mypy configuration to *globally* exclude untyped calls in algosdk -> removing ~50 individual mypy type ignore for algosdk * chore: removing models.py folders in favour of granular modules in root models namespace * chore: wip * feat: initial implementation of TransactionComposer --- legacy_v2_tests/conftest.py | 6 +- pyproject.toml | 9 + src/algokit_utils/__init__.py | 5 +- src/algokit_utils/_debugging.py | 4 +- .../_legacy_v2/_ensure_funded.py | 6 +- src/algokit_utils/_legacy_v2/_transfer.py | 8 +- src/algokit_utils/_legacy_v2/account.py | 14 +- .../_legacy_v2/application_client.py | 8 +- .../_legacy_v2/application_specification.py | 4 +- src/algokit_utils/_legacy_v2/asset.py | 2 +- src/algokit_utils/_legacy_v2/deploy.py | 56 +- src/algokit_utils/_legacy_v2/models.py | 38 +- .../_legacy_v2/network_clients.py | 6 +- src/algokit_utils/accounts/account_manager.py | 2 +- src/algokit_utils/applications/app_manager.py | 355 +++++++ src/algokit_utils/assets/asset_manager.py | 2 + src/algokit_utils/clients/algorand_client.py | 84 +- src/algokit_utils/clients/models.py | 0 src/algokit_utils/models/__init__.py | 3 + src/algokit_utils/models/abi.py | 4 + src/algokit_utils/models/account.py | 35 + src/algokit_utils/models/amount.py | 123 +++ src/algokit_utils/models/application.py | 5 + src/algokit_utils/models/common.py | 0 src/algokit_utils/transactions/models.py | 30 + .../transactions/transaction_composer.py | 905 ++++++++++++++---- .../transactions/transaction_creator.py | 2 + .../transactions/transaction_sender.py | 88 ++ .../applications/__init__.py | 0 .../test_comment_stripping.approved.txt | 30 + .../test_template_substitution.approved.txt | 21 + tests/applications/test_app_manager.py | 77 ++ .../models.py => tests/clients/__init__.py | 0 tests/clients/test_algorand_client.py | 223 +++++ tests/conftest.py | 8 +- tests/test_algorand_client.py | 222 ----- tests/test_transaction_composer.py | 212 ++++ .../transactions/__init__.py | 0 .../artifacts/hello_world/approval.teal | 62 ++ .../artifacts/hello_world/clear.teal | 5 + .../transactions/test_transaction_composer.py | 256 +++++ 41 files changed, 2328 insertions(+), 592 deletions(-) create mode 100644 src/algokit_utils/applications/app_manager.py create mode 100644 src/algokit_utils/assets/asset_manager.py delete mode 100644 src/algokit_utils/clients/models.py create mode 100644 src/algokit_utils/models/abi.py create mode 100644 src/algokit_utils/models/account.py create mode 100644 src/algokit_utils/models/amount.py create mode 100644 src/algokit_utils/models/application.py delete mode 100644 src/algokit_utils/models/common.py create mode 100644 src/algokit_utils/transactions/transaction_creator.py create mode 100644 src/algokit_utils/transactions/transaction_sender.py rename src/algokit_utils/accounts/models.py => tests/applications/__init__.py (100%) create mode 100644 tests/applications/snapshots/test_app_manager.approvals/test_comment_stripping.approved.txt create mode 100644 tests/applications/snapshots/test_app_manager.approvals/test_template_substitution.approved.txt create mode 100644 tests/applications/test_app_manager.py rename src/algokit_utils/applications/models.py => tests/clients/__init__.py (100%) create mode 100644 tests/clients/test_algorand_client.py delete mode 100644 tests/test_algorand_client.py create mode 100644 tests/test_transaction_composer.py rename src/algokit_utils/assets/models.py => tests/transactions/__init__.py (100%) create mode 100644 tests/transactions/artifacts/hello_world/approval.teal create mode 100644 tests/transactions/artifacts/hello_world/clear.teal create mode 100644 tests/transactions/test_transaction_composer.py diff --git a/legacy_v2_tests/conftest.py b/legacy_v2_tests/conftest.py index e3997a2c..dbe4be46 100644 --- a/legacy_v2_tests/conftest.py +++ b/legacy_v2_tests/conftest.py @@ -188,11 +188,11 @@ def generate_test_asset(algod_client: "AlgodClient", sender: Account, total: int note=None, lease=None, rekey_to=None, - ) # type: ignore[no-untyped-call] + ) - signed_transaction = txn.sign(sender.private_key) # type: ignore[no-untyped-call] + signed_transaction = txn.sign(sender.private_key) algod_client.send_transaction(signed_transaction) - ptx = algod_client.pending_transaction_info(txn.get_txid()) # type: ignore[no-untyped-call] + ptx = algod_client.pending_transaction_info(txn.get_txid()) if isinstance(ptx, dict) and "asset-index" in ptx and isinstance(ptx["asset-index"], int): return ptx["asset-index"] diff --git a/pyproject.toml b/pyproject.toml index 4e3a99a9..bd391ae5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -125,6 +125,7 @@ suppress-none-returning = true [tool.ruff.lint.per-file-ignores] "src/algokit_utils/beta/*" = ["ERA001", "E501", "PLR0911"] "path/to/file.py" = ["E402"] +"tests/clients/test_algorand_client.py" = ["ERA001"] [tool.poe.tasks] docs = ["docs-html-only", "docs-md-only"] @@ -149,6 +150,14 @@ disallow_any_generics = false implicit_reexport = false show_error_codes = true +untyped_calls_exclude = [ + "algosdk", +] + +[[tool.mypy.overrides]] +module = ["algosdk", "algosdk.*"] +disallow_untyped_calls = false + [tool.semantic_release] version_toml = "pyproject.toml:tool.poetry.version" remove_dist = false diff --git a/src/algokit_utils/__init__.py b/src/algokit_utils/__init__.py index 77959758..d89bad9b 100644 --- a/src/algokit_utils/__init__.py +++ b/src/algokit_utils/__init__.py @@ -30,9 +30,7 @@ from algokit_utils._legacy_v2.asset import opt_in, opt_out from algokit_utils._legacy_v2.common import Program from algokit_utils._legacy_v2.deploy import ( - DELETABLE_TEMPLATE_NAME, NOTE_PREFIX, - UPDATABLE_TEMPLATE_NAME, ABICallArgs, ABICallArgsDict, ABICreateCallArgs, @@ -61,7 +59,6 @@ ABIArgsDict, ABIMethod, ABITransactionResponse, - Account, CommonCallParameters, CommonCallParametersDict, CreateCallParameters, @@ -91,6 +88,8 @@ DispenserLimitResponse, TestNetDispenserApiClient, ) +from algokit_utils.models.account import Account +from algokit_utils.models.application import DELETABLE_TEMPLATE_NAME, UPDATABLE_TEMPLATE_NAME __all__ = [ # ==== LEGACY V2 EXPORTS BEGIN ==== diff --git a/src/algokit_utils/_debugging.py b/src/algokit_utils/_debugging.py index e8c0ef52..de5ed182 100644 --- a/src/algokit_utils/_debugging.py +++ b/src/algokit_utils/_debugging.py @@ -157,9 +157,7 @@ def _build_avm_sourcemap( # noqa: PLR0913 raise ValueError("Either raw teal or compiled teal must be provided") result = compiled_teal if compiled_teal else Program(str(raw_teal), client=client) - program_hash = base64.b64encode( - checksum(result.raw_binary) # type: ignore[no-untyped-call] - ).decode() + program_hash = base64.b64encode(checksum(result.raw_binary)).decode() source_map = result.source_map.__dict__ source_map["sources"] = [f"{file_name}{TEAL_FILE_EXT}"] if with_sources else [] diff --git a/src/algokit_utils/_legacy_v2/_ensure_funded.py b/src/algokit_utils/_legacy_v2/_ensure_funded.py index 23c87860..2db90f36 100644 --- a/src/algokit_utils/_legacy_v2/_ensure_funded.py +++ b/src/algokit_utils/_legacy_v2/_ensure_funded.py @@ -7,12 +7,12 @@ from algokit_utils._legacy_v2._transfer import TransferParameters, transfer from algokit_utils._legacy_v2.account import get_dispenser_account -from algokit_utils._legacy_v2.models import Account from algokit_utils._legacy_v2.network_clients import is_testnet from algokit_utils.clients.dispenser_api_client import ( DispenserAssetName, TestNetDispenserApiClient, ) +from algokit_utils.models.account import Account @dataclass(kw_only=True) @@ -63,7 +63,7 @@ def _get_address_to_fund(parameters: EnsureBalanceParameters) -> str: if isinstance(parameters.account_to_fund, str): return parameters.account_to_fund else: - return str(address_from_private_key(parameters.account_to_fund.private_key)) # type: ignore[no-untyped-call] + return str(address_from_private_key(parameters.account_to_fund.private_key)) def _get_account_info(client: AlgodClient, address_to_fund: str) -> dict: @@ -111,7 +111,7 @@ def _fund_using_transfer( fee_micro_algos=parameters.fee_micro_algos, ), ) - transaction_id = response.get_txid() # type: ignore[no-untyped-call] + transaction_id = response.get_txid() return EnsureFundedResponse(transaction_id=transaction_id, amount=response.amt) diff --git a/src/algokit_utils/_legacy_v2/_transfer.py b/src/algokit_utils/_legacy_v2/_transfer.py index baca5b2b..6b59cd4c 100644 --- a/src/algokit_utils/_legacy_v2/_transfer.py +++ b/src/algokit_utils/_legacy_v2/_transfer.py @@ -7,7 +7,7 @@ from algosdk.atomic_transaction_composer import AccountTransactionSigner from algosdk.transaction import AssetTransferTxn, PaymentTxn, SuggestedParams -from algokit_utils._legacy_v2.models import Account +from algokit_utils.models.account import Account if TYPE_CHECKING: from algosdk.v2client.algod import AlgodClient @@ -93,7 +93,7 @@ def transfer(client: "AlgodClient", parameters: TransferParameters) -> PaymentTx amt=params.micro_algos, note=params.note.encode("utf-8") if isinstance(params.note, str) else params.note, sp=params.suggested_params, - ) # type: ignore[no-untyped-call] + ) result = _send_transaction(client=client, transaction=transaction, parameters=params) assert isinstance(result, PaymentTxn) @@ -117,7 +117,7 @@ def transfer_asset(client: "AlgodClient", parameters: TransferAssetParameters) - note=params.note, index=params.asset_id, rekey_to=None, - ) # type: ignore[no-untyped-call] + ) result = _send_transaction(client=client, transaction=xfer_txn, parameters=params) assert isinstance(result, AssetTransferTxn) @@ -148,5 +148,5 @@ def _get_address(account: Account | AccountTransactionSigner) -> str: if type(account) is Account: return account.address else: - address = address_from_private_key(account.private_key) # type: ignore[no-untyped-call] + address = address_from_private_key(account.private_key) return str(address) diff --git a/src/algokit_utils/_legacy_v2/account.py b/src/algokit_utils/_legacy_v2/account.py index 819a448f..d98a875a 100644 --- a/src/algokit_utils/_legacy_v2/account.py +++ b/src/algokit_utils/_legacy_v2/account.py @@ -7,8 +7,8 @@ from algosdk.util import algos_to_microalgos from algokit_utils._legacy_v2._transfer import TransferParameters, transfer -from algokit_utils._legacy_v2.models import Account from algokit_utils._legacy_v2.network_clients import get_kmd_client_from_algod_client, is_localnet +from algokit_utils.models.account import Account if TYPE_CHECKING: from collections.abc import Callable @@ -32,8 +32,8 @@ def get_account_from_mnemonic(mnemonic: str) -> Account: """Convert a mnemonic (25 word passphrase) into an Account""" - private_key = to_private_key(mnemonic) # type: ignore[no-untyped-call] - address = address_from_private_key(private_key) # type: ignore[no-untyped-call] + private_key = to_private_key(mnemonic) + address = address_from_private_key(private_key) return Account(private_key=private_key, address=address) @@ -47,7 +47,7 @@ def create_kmd_wallet_account(kmd_client: "KMDClient", name: str) -> Account: account_key = key_ids[0] private_account_key = kmd_client.export_key(wallet_handle, "", account_key) - return get_account_from_mnemonic(from_private_key(private_account_key)) # type: ignore[no-untyped-call] + return get_account_from_mnemonic(from_private_key(private_account_key)) def get_or_create_kmd_wallet_account( @@ -79,7 +79,7 @@ def get_or_create_kmd_wallet_account( TransferParameters( from_account=get_dispenser_account(client), to_address=account.address, - micro_algos=algos_to_microalgos(fund_with_algos), # type: ignore[no-untyped-call] + micro_algos=algos_to_microalgos(fund_with_algos), ), ) @@ -139,7 +139,7 @@ def get_kmd_wallet_account( return None private_account_key = kmd_client.export_key(wallet_handle, "", matched_account_key) - return get_account_from_mnemonic(from_private_key(private_account_key)) # type: ignore[no-untyped-call] + return get_account_from_mnemonic(from_private_key(private_account_key)) def get_account( @@ -177,7 +177,7 @@ def get_account( if is_localnet(client): account = get_or_create_kmd_wallet_account(client, name, fund_with_algos, kmd_client) - os.environ[mnemonic_key] = from_private_key(account.private_key) # type: ignore[no-untyped-call] + os.environ[mnemonic_key] = from_private_key(account.private_key) return account raise Exception(f"Missing environment variable '{mnemonic_key}' when looking for account '{name}'") diff --git a/src/algokit_utils/_legacy_v2/application_client.py b/src/algokit_utils/_legacy_v2/application_client.py index 32851fa4..a52639d1 100644 --- a/src/algokit_utils/_legacy_v2/application_client.py +++ b/src/algokit_utils/_legacy_v2/application_client.py @@ -43,7 +43,6 @@ ABIArgType, ABIMethod, ABITransactionResponse, - Account, CreateCallParameters, CreateCallParametersDict, OnCompleteCallParameters, @@ -54,6 +53,7 @@ TransactionResponse, ) from algokit_utils.config import config +from algokit_utils.models.account import Account if typing.TYPE_CHECKING: from algosdk.v2client.algod import AlgodClient @@ -1021,7 +1021,7 @@ def add_method_call( # noqa: PLR0913 raise Exception(f"ABI arguments specified on a bare call: {', '.join(abi_args)}") atc.add_transaction( TransactionWithSigner( - txn=transaction.ApplicationCallTxn( # type: ignore[no-untyped-call] + txn=transaction.ApplicationCallTxn( sender=sender, sp=sp, index=app_id, @@ -1329,11 +1329,11 @@ def get_sender_from_signer(signer: TransactionSigner | None) -> str | None: """Returns the associated address of a signer, return None if no address found""" if isinstance(signer, AccountTransactionSigner): - sender = address_from_private_key(signer.private_key) # type: ignore[no-untyped-call] + sender = address_from_private_key(signer.private_key) assert isinstance(sender, str) return sender elif isinstance(signer, MultisigTransactionSigner): - sender = signer.msig.address() # type: ignore[no-untyped-call] + sender = signer.msig.address() assert isinstance(sender, str) return sender elif isinstance(signer, LogicSigTransactionSigner): diff --git a/src/algokit_utils/_legacy_v2/application_specification.py b/src/algokit_utils/_legacy_v2/application_specification.py index 392fce8d..865dece5 100644 --- a/src/algokit_utils/_legacy_v2/application_specification.py +++ b/src/algokit_utils/_legacy_v2/application_specification.py @@ -130,7 +130,7 @@ def _encode_state_schema(schema: StateSchema) -> dict[str, int]: def _decode_state_schema(data: dict[str, int]) -> StateSchema: - return StateSchema( # type: ignore[no-untyped-call] + return StateSchema( num_byte_slices=data.get("num_byte_slices", 0), num_uints=data.get("num_uints", 0), ) @@ -203,4 +203,4 @@ def export(self, directory: Path | str | None = None) -> None: def _state_schema(schema: dict[str, int]) -> StateSchema: - return StateSchema(schema.get("num-uint", 0), schema.get("num-byte-slice", 0)) # type: ignore[no-untyped-call] + return StateSchema(schema.get("num-uint", 0), schema.get("num-byte-slice", 0)) diff --git a/src/algokit_utils/_legacy_v2/asset.py b/src/algokit_utils/_legacy_v2/asset.py index 2ef4860f..2f71cbf8 100644 --- a/src/algokit_utils/_legacy_v2/asset.py +++ b/src/algokit_utils/_legacy_v2/asset.py @@ -10,7 +10,7 @@ from enum import Enum, auto -from algokit_utils._legacy_v2.models import Account +from algokit_utils.models.account import Account __all__ = ["opt_in", "opt_out"] logger = logging.getLogger(__name__) diff --git a/src/algokit_utils/_legacy_v2/deploy.py b/src/algokit_utils/_legacy_v2/deploy.py index 561ce413..ed0bd0e5 100644 --- a/src/algokit_utils/_legacy_v2/deploy.py +++ b/src/algokit_utils/_legacy_v2/deploy.py @@ -11,6 +11,7 @@ from algosdk.atomic_transaction_composer import AtomicTransactionComposer, TransactionSigner from algosdk.logic import get_application_address from algosdk.transaction import StateSchema +from deprecated import deprecated from algokit_utils._legacy_v2.application_specification import ( ApplicationSpecification, @@ -21,10 +22,11 @@ from algokit_utils._legacy_v2.models import ( ABIArgsDict, ABIMethod, - Account, CreateCallParameters, TransactionResponse, ) +from algokit_utils.applications.app_manager import AppManager +from algokit_utils.models.account import Account if TYPE_CHECKING: from algosdk.v2client.algod import AlgodClient @@ -185,7 +187,7 @@ def get_creator_apps(indexer: "IndexerClient", creator_account: Account | str) - while True: response = indexer.lookup_account_application_by_creator( creator_address, limit=DEFAULT_INDEXER_MAX_API_RESOURCES_PER_ACCOUNT, next_page=token - ) # type: ignore[no-untyped-call] + ) if "message" in response: # an error occurred raise Exception(f"Error querying applications for {creator_address}: {response}") for app in response["applications"]: @@ -199,7 +201,7 @@ def get_creator_apps(indexer: "IndexerClient", creator_account: Account | str) - address=creator_address, address_role="sender", note_prefix=NOTE_PREFIX.encode("utf-8"), - ) # type: ignore[no-untyped-call] + ) transactions: list[dict] = search_transactions_response["transactions"] if not transactions: continue @@ -236,7 +238,7 @@ def get_creator_apps(indexer: "IndexerClient", creator_account: Account | str) - def _state_schema(schema: dict[str, int]) -> StateSchema: - return StateSchema(schema.get("num-uint", 0), schema.get("num-byte-slice", 0)) # type: ignore[no-untyped-call] + return StateSchema(schema.get("num-uint", 0), schema.get("num-byte-slice", 0)) def _describe_schema_breaks(prefix: str, from_schema: StateSchema, to_schema: StateSchema) -> Iterable[str]: @@ -288,33 +290,6 @@ def _is_valid_token_character(char: str) -> bool: return char.isalnum() or char == "_" -def _replace_template_variable(program_lines: list[str], template_variable: str, value: str) -> tuple[list[str], int]: - result: list[str] = [] - match_count = 0 - token = f"TMPL_{template_variable}" - token_idx_offset = len(value) - len(token) - for line in program_lines: - comment_idx = _find_unquoted_string(line, "//") - if comment_idx is None: - comment_idx = len(line) - code = line[:comment_idx] - comment = line[comment_idx:] - trailing_idx = 0 - while True: - token_idx = _find_template_token(code, token, trailing_idx) - if token_idx is None: - break - - trailing_idx = token_idx + len(token) - prefix = code[:token_idx] - suffix = code[trailing_idx:] - code = f"{prefix}{value}{suffix}" - match_count += 1 - trailing_idx += token_idx_offset - result.append(code + comment) - return result, match_count - - def add_deploy_template_variables( template_values: TemplateValueDict, allow_update: bool | None, allow_delete: bool | None ) -> None: @@ -437,6 +412,7 @@ def check_template_variables(approval_program: str, template_values: TemplateVal logger.warning(f"{tmpl_variable} not found in approval program, but variable was provided") +@deprecated(reason="Use `AppManager.replace_template_variables` instead", version="3.0.0") def replace_template_variables(program: str, template_values: TemplateValueMapping) -> str: """Replaces `TMPL_*` variables in `program` with `template_values` @@ -444,23 +420,7 @@ def replace_template_variables(program: str, template_values: TemplateValueMappi `template_values` keys should *NOT* be prefixed with `TMPL_` ``` """ - program_lines = program.splitlines() - for template_variable_name, template_value in template_values.items(): - match template_value: - case int(): - value = str(template_value) - case str(): - value = "0x" + template_value.encode("utf-8").hex() - case bytes(): - value = "0x" + template_value.hex() - case _: - raise DeploymentFailedError( - f"Unexpected template value type {template_variable_name}: {template_value.__class__}" - ) - - program_lines, matches = _replace_template_variable(program_lines, template_variable_name, value) - - return "\n".join(program_lines) + return AppManager.replace_template_variables(program, template_values) def has_template_vars(app_spec: ApplicationSpecification) -> bool: diff --git a/src/algokit_utils/_legacy_v2/models.py b/src/algokit_utils/_legacy_v2/models.py index cc5d34d2..d20bed83 100644 --- a/src/algokit_utils/_legacy_v2/models.py +++ b/src/algokit_utils/_legacy_v2/models.py @@ -2,23 +2,22 @@ from collections.abc import Sequence from typing import Any, Generic, Protocol, TypeAlias, TypedDict, TypeVar -import algosdk.account from algosdk import transaction from algosdk.abi import Method from algosdk.atomic_transaction_composer import ( - AccountTransactionSigner, AtomicTransactionResponse, SimulateAtomicTransactionResponse, TransactionSigner, ) -from algosdk.encoding import decode_address from deprecated import deprecated +# Imports from latest sdk version that rely on models previously used in legacy v2 (but moved to root models/*) + + __all__ = [ "ABIArgsDict", "ABIMethod", "ABITransactionResponse", - "Account", "CreateCallParameters", "CreateCallParametersDict", "CreateTransactionParameters", @@ -31,37 +30,6 @@ ReturnType = TypeVar("ReturnType") -@dataclasses.dataclass(kw_only=True) -class Account: - """Holds the private_key and address for an account""" - - private_key: str - """Base64 encoded private key""" - address: str = dataclasses.field(default="") - """Address for this account""" - - def __post_init__(self) -> None: - if not self.address: - self.address = algosdk.account.address_from_private_key(self.private_key) # type: ignore[no-untyped-call] - - @property - def public_key(self) -> bytes: - """The public key for this account""" - public_key = decode_address(self.address) # type: ignore[no-untyped-call] - assert isinstance(public_key, bytes) - return public_key - - @property - def signer(self) -> AccountTransactionSigner: - """An AccountTransactionSigner for this account""" - return AccountTransactionSigner(self.private_key) - - @staticmethod - def new_account() -> "Account": - private_key, address = algosdk.account.generate_account() # type: ignore[no-untyped-call] - return Account(private_key=private_key) - - @dataclasses.dataclass(kw_only=True) class TransactionResponse: """Response for a non ABI call""" diff --git a/src/algokit_utils/_legacy_v2/network_clients.py b/src/algokit_utils/_legacy_v2/network_clients.py index 2de270da..b1bcc2cb 100644 --- a/src/algokit_utils/_legacy_v2/network_clients.py +++ b/src/algokit_utils/_legacy_v2/network_clients.py @@ -70,7 +70,7 @@ def get_kmd_client(config: AlgoClientConfig | None = None) -> KMDClient: 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] + return KMDClient(config.token, config.server) def get_indexer_client(config: AlgoClientConfig | None = None) -> IndexerClient: @@ -79,7 +79,7 @@ def get_indexer_client(config: AlgoClientConfig | None = None) -> IndexerClient: If no configuration provided will use environment variables `INDEXER_SERVER`, `INDEXER_PORT` and `INDEXER_TOKEN`""" config = config or _get_config_from_environment("INDEXER") headers = {"X-Indexer-API-Token": config.token} - return IndexerClient(config.token, config.server, headers) # type: ignore[no-untyped-call] + return IndexerClient(config.token, config.server, headers) def is_localnet(client: AlgodClient) -> bool: @@ -109,7 +109,7 @@ def get_kmd_client_from_algod_client(client: AlgodClient) -> KMDClient: # (e.g. same token and server as algod and port 4002 by default) port = os.getenv("KMD_PORT", "4002") server = _replace_kmd_port(client.algod_address, port) - return KMDClient(client.algod_token, server) # type: ignore[no-untyped-call] + return KMDClient(client.algod_token, server) def _replace_kmd_port(address: str, port: str) -> str: diff --git a/src/algokit_utils/accounts/account_manager.py b/src/algokit_utils/accounts/account_manager.py index a2527c6c..d4d95d19 100644 --- a/src/algokit_utils/accounts/account_manager.py +++ b/src/algokit_utils/accounts/account_manager.py @@ -109,7 +109,7 @@ def random(self) -> AddressAndSigner: :return: The account """ - (sk, addr) = generate_account() # type: ignore[no-untyped-call] + (sk, addr) = generate_account() signer = AccountTransactionSigner(sk) self.set_signer(addr, signer) diff --git a/src/algokit_utils/applications/app_manager.py b/src/algokit_utils/applications/app_manager.py new file mode 100644 index 00000000..91b0a407 --- /dev/null +++ b/src/algokit_utils/applications/app_manager.py @@ -0,0 +1,355 @@ +import base64 +from collections.abc import Mapping +from dataclasses import dataclass +from enum import IntEnum +from typing import Any, TypeAlias + +import algosdk +import algosdk.atomic_transaction_composer +import algosdk.box_reference +from algosdk.atomic_transaction_composer import AccountTransactionSigner +from algosdk.logic import get_application_address +from algosdk.v2client import algod + +from algokit_utils.models.abi import ABIValue +from algokit_utils.models.application import DELETABLE_TEMPLATE_NAME, UPDATABLE_TEMPLATE_NAME + + +@dataclass(frozen=True) +class BoxName: + name: str + name_raw: bytes + name_base64: str + + +@dataclass(frozen=True) +class AppState: + key_raw: bytes + key_base64: str + value_raw: bytes | None + value_base64: str | None + value: str | int + + +class DataTypeFlag(IntEnum): + BYTES = 1 + UINT = 2 + + +TealTemplateParams: TypeAlias = Mapping[str, str | int | bytes] | dict[str, str | int | bytes] + + +@dataclass(frozen=True) +class AppInformation: + app_id: int + app_address: str + approval_program: bytes + clear_state_program: bytes + creator: str + global_state: dict[str, AppState] + local_ints: int + local_byte_slices: int + global_ints: int + global_byte_slices: int + extra_program_pages: int | None + + +@dataclass(frozen=True) +class CompiledTeal: + teal: str + compiled: bytes + compiled_hash: str + compiled_base64_to_bytes: bytes + source_map: dict | None + + +BoxIdentifier = str | bytes | AccountTransactionSigner + + +def _is_valid_token_character(char: str) -> bool: + return char.isalnum() or char == "_" + + +def _last_token_base64(line: str, idx: int) -> bool: + try: + *_, last = line[:idx].split() + except ValueError: + return False + return last in ("base64", "b64") + + +def _find_template_token(line: str, token: str, start: int = 0, end: int = -1) -> int | None: + """Find the first template token within a line of TEAL. Only matches outside of quotes are returned. + Only full token matches are returned, i.e. TMPL_STR will not match against TMPL_STRING + Returns None if not found""" + if end < 0: + end = len(line) + + idx = start + while idx < end: + token_idx = _find_unquoted_string(line, token, idx, end) + if token_idx is None: + break + trailing_idx = token_idx + len(token) + if (token_idx == 0 or not _is_valid_token_character(line[token_idx - 1])) and ( # word boundary at start + trailing_idx >= len(line) or not _is_valid_token_character(line[trailing_idx]) # word boundary at end + ): + return token_idx + idx = trailing_idx + return None + + +def _find_unquoted_string(line: str, token: str, start: int = 0, end: int = -1) -> int | None: + """Find the first string within a line of TEAL. Only matches outside of quotes and base64 are returned. + Returns None if not found""" + + if end < 0: + end = len(line) + idx = start + in_quotes = in_base64 = False + while idx < end: + current_char = line[idx] + match current_char: + # enter base64 + case " " | "(" if not in_quotes and _last_token_base64(line, idx): + in_base64 = True + # exit base64 + case " " | ")" if not in_quotes and in_base64: + in_base64 = False + # escaped char + case "\\" if in_quotes: + # skip next character + idx += 1 + # quote boundary + case '"': + in_quotes = not in_quotes + # can test for match + case _ if not in_quotes and not in_base64 and line.startswith(token, idx): + # only match if not in quotes and string matches + return idx + idx += 1 + return None + + +def _replace_template_variable(program_lines: list[str], template_variable: str, value: str) -> tuple[list[str], int]: + result: list[str] = [] + match_count = 0 + token = f"TMPL_{template_variable}" + token_idx_offset = len(value) - len(token) + for line in program_lines: + comment_idx = _find_unquoted_string(line, "//") + if comment_idx is None: + comment_idx = len(line) + code = line[:comment_idx] + comment = line[comment_idx:] + trailing_idx = 0 + while True: + token_idx = _find_template_token(code, token, trailing_idx) + if token_idx is None: + break + + trailing_idx = token_idx + len(token) + prefix = code[:token_idx] + suffix = code[trailing_idx:] + code = f"{prefix}{value}{suffix}" + match_count += 1 + trailing_idx += token_idx_offset + result.append(code + comment) + return result, match_count + + +class AppManager: + def __init__(self, algod_client: algod.AlgodClient): + self._algod = algod_client + self._compilation_results: dict[str, CompiledTeal] = {} + + def compile_teal(self, teal_code: str) -> CompiledTeal: + if teal_code in self._compilation_results: + return self._compilation_results[teal_code] + + compiled = self._algod.compile(teal_code, source_map=True) + result = CompiledTeal( + teal=teal_code, + compiled=compiled["result"], + compiled_hash=compiled["hash"], + compiled_base64_to_bytes=base64.b64decode(compiled["result"]), + source_map=compiled.get("sourcemap"), + ) + self._compilation_results[teal_code] = result + return result + + def compile_teal_template( + self, + teal_template_code: str, + template_params: TealTemplateParams | None = None, + deployment_metadata: dict[str, bool] | None = None, + ) -> CompiledTeal: + teal_code = AppManager.strip_teal_comments(teal_template_code) + teal_code = AppManager.replace_template_variables(teal_code, template_params or {}) + + if deployment_metadata: + teal_code = AppManager.replace_teal_template_deploy_time_control_params(teal_code, deployment_metadata) + + return self.compile_teal(teal_code) + + def get_compilation_result(self, teal_code: str) -> CompiledTeal | None: + return self._compilation_results.get(teal_code) + + def get_by_id(self, app_id: int) -> AppInformation: + app = self._algod.application_info(app_id) + assert isinstance(app, dict) + app_params = app["params"] + + return AppInformation( + app_id=app_id, + app_address=get_application_address(app_id), + approval_program=base64.b64decode(app_params["approval-program"]), + clear_state_program=base64.b64decode(app_params["clear-state-program"]), + creator=app_params["creator"], + local_ints=app_params["local-state-schema"]["num-uint"], + local_byte_slices=app_params["local-state-schema"]["num-byte-slice"], + global_ints=app_params["global-state-schema"]["num-uint"], + global_byte_slices=app_params["global-state-schema"]["num-byte-slice"], + extra_program_pages=app_params.get("extra-program-pages", 0), + global_state=self.decode_app_state(app_params.get("global-state", [])), + ) + + def get_global_state(self, app_id: int) -> dict[str, AppState]: + return self.get_by_id(app_id).global_state + + def get_local_state(self, app_id: int, address: str) -> dict[str, AppState]: + app_info = self._algod.account_application_info(address, app_id) + assert isinstance(app_info, dict) + if not app_info.get("app-local-state", {}).get("key-value"): + raise ValueError("Couldn't find local state") + return self.decode_app_state(app_info["app-local-state"]["key-value"]) + + def get_box_names(self, app_id: int) -> list[BoxName]: + box_result = self._algod.application_boxes(app_id) + assert isinstance(box_result, dict) + return [ + BoxName( + name_raw=base64.b64decode(b["name"]), + name_base64=b["name"], + name=base64.b64decode(b["name"]).decode("utf-8"), + ) + for b in box_result["boxes"] + ] + + def get_box_value(self, app_id: int, box_name: BoxIdentifier) -> bytes: + name = b"" + if isinstance(box_name, str): + name = box_name.encode("utf-8") + elif isinstance(box_name, bytes): + name = box_name + elif isinstance(box_name, AccountTransactionSigner): + name = algosdk.encoding.decode_address(algosdk.account.address_from_private_key(box_name.private_key)) + else: + raise ValueError(f"Invalid box identifier type: {type(box_name)}") + + box_result = self._algod.application_box_by_name(app_id, name) + assert isinstance(box_result, dict) + return base64.b64decode(box_result["value"]) + + def get_box_values(self, app_id: int, box_names: list[BoxIdentifier]) -> list[bytes]: + return [self.get_box_value(app_id, box_name) for box_name in box_names] + + def get_box_value_from_abi_type( + self, app_id: int, box_name: BoxIdentifier, abi_type: algosdk.abi.ABIType + ) -> ABIValue: + value = self.get_box_value(app_id, box_name) + try: + return abi_type.decode(value) # type: ignore[no-any-return] + except Exception as e: + raise ValueError(f"Failed to decode box value {value.decode('utf-8')} with ABI type {abi_type}") from e + + def get_box_values_from_abi_type( + self, app_id: int, box_names: list[BoxIdentifier], abi_type: algosdk.abi.ABIType + ) -> list[ABIValue]: + return [self.get_box_value_from_abi_type(app_id, box_name, abi_type) for box_name in box_names] + + @staticmethod + def decode_app_state(state: list[dict[str, Any]]) -> dict[str, AppState]: + state_values: dict[str, AppState] = {} + + for state_val in state: + key_base64 = state_val["key"] + key_raw = base64.b64decode(key_base64) + key = key_raw.decode("utf-8") + teal_value = state_val["value"] + + data_type_flag = teal_value.get("action", teal_value.get("type")) + + if data_type_flag == DataTypeFlag.BYTES: + value_base64 = teal_value.get("bytes", "") + value_raw = base64.b64decode(value_base64) + state_values[key] = AppState( + key_raw=key_raw, + key_base64=key_base64, + value_raw=value_raw, + value_base64=value_base64, + value=value_raw.decode("utf-8"), + ) + elif data_type_flag == DataTypeFlag.UINT: + value = teal_value.get("uint", 0) + state_values[key] = AppState( + key_raw=key_raw, + key_base64=key_base64, + value_raw=None, + value_base64=None, + value=int(value), + ) + else: + raise ValueError(f"Received unknown state data type of {data_type_flag}") + + return state_values + + @staticmethod + def replace_template_variables(program: str, template_values: TealTemplateParams) -> str: + program_lines = program.splitlines() + for template_variable_name, template_value in template_values.items(): + match template_value: + case int(): + value = str(template_value) + case str(): + value = "0x" + template_value.encode("utf-8").hex() + case bytes(): + value = "0x" + template_value.hex() + case _: + raise ValueError( + f"Unexpected template value type {template_variable_name}: {template_value.__class__}" + ) + + program_lines, _ = _replace_template_variable(program_lines, template_variable_name, value) + + return "\n".join(program_lines) + + @staticmethod + def replace_teal_template_deploy_time_control_params(teal_template_code: str, params: dict[str, bool]) -> str: + if params.get("updatable") is not None: + if UPDATABLE_TEMPLATE_NAME not in teal_template_code: + raise ValueError( + f"Deploy-time updatability control requested for app deployment, but {UPDATABLE_TEMPLATE_NAME} " + "not present in TEAL code" + ) + teal_template_code = teal_template_code.replace(UPDATABLE_TEMPLATE_NAME, str(int(params["updatable"]))) + + if params.get("deletable") is not None: + if DELETABLE_TEMPLATE_NAME not in teal_template_code: + raise ValueError( + f"Deploy-time deletability control requested for app deployment, but {DELETABLE_TEMPLATE_NAME} " + "not present in TEAL code" + ) + teal_template_code = teal_template_code.replace(DELETABLE_TEMPLATE_NAME, str(int(params["deletable"]))) + + return teal_template_code + + @staticmethod + def strip_teal_comments(teal_code: str) -> str: + def _strip_comment(line: str) -> str: + comment_idx = _find_unquoted_string(line, "//") + if comment_idx is None: + return line + return line[:comment_idx].rstrip() + + return "\n".join(_strip_comment(line) for line in teal_code.splitlines()) diff --git a/src/algokit_utils/assets/asset_manager.py b/src/algokit_utils/assets/asset_manager.py new file mode 100644 index 00000000..4bef4802 --- /dev/null +++ b/src/algokit_utils/assets/asset_manager.py @@ -0,0 +1,2 @@ +class AssetManager: + """A manager for Algorand assets""" diff --git a/src/algokit_utils/clients/algorand_client.py b/src/algokit_utils/clients/algorand_client.py index 7e02e20a..f4851daf 100644 --- a/src/algokit_utils/clients/algorand_client.py +++ b/src/algokit_utils/clients/algorand_client.py @@ -9,6 +9,8 @@ from typing_extensions import Self from algokit_utils.accounts.account_manager import AccountManager +from algokit_utils.applications.app_manager import AppManager +from algokit_utils.assets.asset_manager import AssetManager from algokit_utils.clients.client_manager import AlgoSdkClients, ClientManager from algokit_utils.network_clients import ( AlgoClientConfigs, @@ -20,29 +22,31 @@ ) from algokit_utils.transactions.transaction_composer import ( AppCallParams, + AppMethodCallParams, AssetConfigParams, AssetCreateParams, AssetDestroyParams, AssetFreezeParams, AssetOptInParams, AssetTransferParams, - MethodCallParams, - OnlineKeyRegParams, - PayParams, + OnlineKeyRegistrationParams, + PaymentParams, TransactionComposer, ) +from algokit_utils.transactions.transaction_creator import AlgorandClientTransactionCreator +from algokit_utils.transactions.transaction_sender import AlgorandClientTransactionSender __all__ = [ "AlgorandClient", "AssetCreateParams", "AssetOptInParams", - "MethodCallParams", - "PayParams", + "AppMethodCallParams", + "PaymentParams", "AssetFreezeParams", "AssetConfigParams", "AssetDestroyParams", "AppCallParams", - "OnlineKeyRegParams", + "OnlineKeyRegistrationParams", "AssetTransferParams", ] @@ -53,15 +57,15 @@ class AlgorandClientSendMethods: Methods used to send a transaction to the network and wait for confirmation """ - payment: Callable[[PayParams], dict[str, Any]] + payment: Callable[[PaymentParams], 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]] + online_key_reg: Callable[[OnlineKeyRegistrationParams], dict[str, Any]] + method_call: Callable[[AppMethodCallParams], dict[str, Any]] asset_opt_in: Callable[[AssetOptInParams], dict[str, Any]] @@ -71,15 +75,15 @@ class AlgorandClientTransactionMethods: Methods used to form a transaction without signing or sending to the network """ - payment: Callable[[PayParams], Transaction] + payment: Callable[[PaymentParams], 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]] + online_key_reg: Callable[[OnlineKeyRegistrationParams], Transaction] + method_call: Callable[[AppMethodCallParams], list[Transaction]] asset_opt_in: Callable[[AssetOptInParams], Transaction] @@ -89,6 +93,15 @@ class AlgorandClient: def __init__(self, config: AlgoClientConfigs | AlgoSdkClients): self._client_manager: ClientManager = ClientManager(config) self._account_manager: AccountManager = AccountManager(self._client_manager) + self._asset_manager: AssetManager = AssetManager() # TODO: implement + self._app_manager: AppManager = AppManager(self._client_manager.algod) # TODO: implement + self._transaction_sender = AlgorandClientTransactionSender( + new_group=lambda: self.new_group(), + asset_manager=self._asset_manager, + app_manager=self._app_manager, + algod_client=self._client_manager.algod, + ) + self._transaction_creator = AlgorandClientTransactionCreator() # TODO: implement self._cached_suggested_params: SuggestedParams | None = None self._cached_suggested_params_expiry: float | None = None @@ -187,53 +200,14 @@ def new_group(self) -> TransactionComposer: ) @property - def send(self) -> AlgorandClientSendMethods: + def send(self) -> AlgorandClientTransactionSender: """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() - ), - ) + return self._transaction_sender @property - def transactions(self) -> AlgorandClientTransactionMethods: + def create_transaction(self) -> AlgorandClientTransactionCreator: """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, - ) + return self._transaction_creator @staticmethod def default_local_net() -> "AlgorandClient": diff --git a/src/algokit_utils/clients/models.py b/src/algokit_utils/clients/models.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/algokit_utils/models/__init__.py b/src/algokit_utils/models/__init__.py index bcffc093..baf4664d 100644 --- a/src/algokit_utils/models/__init__.py +++ b/src/algokit_utils/models/__init__.py @@ -1 +1,4 @@ from algokit_utils._legacy_v2.models import * # noqa: F403 + +from .abi import * # noqa: F403 +from .account import * # noqa: F403 diff --git a/src/algokit_utils/models/abi.py b/src/algokit_utils/models/abi.py new file mode 100644 index 00000000..767eed09 --- /dev/null +++ b/src/algokit_utils/models/abi.py @@ -0,0 +1,4 @@ +ABIPrimitiveValue = bool | int | str | bytes | bytearray + +# NOTE: This is present in js-algorand-sdk, but sadly not in untyped py-algorand-sdk +ABIValue = ABIPrimitiveValue | list["ABIValue"] | dict[str, "ABIValue"] diff --git a/src/algokit_utils/models/account.py b/src/algokit_utils/models/account.py new file mode 100644 index 00000000..3014b7af --- /dev/null +++ b/src/algokit_utils/models/account.py @@ -0,0 +1,35 @@ +import dataclasses + +import algosdk +from algosdk.atomic_transaction_composer import AccountTransactionSigner + + +@dataclasses.dataclass(kw_only=True) +class Account: + """Holds the private_key and address for an account""" + + private_key: str + """Base64 encoded private key""" + address: str = dataclasses.field(default="") + """Address for this account""" + + def __post_init__(self) -> None: + if not self.address: + self.address = algosdk.account.address_from_private_key(self.private_key) + + @property + def public_key(self) -> bytes: + """The public key for this account""" + public_key = algosdk.encoding.decode_address(self.address) + assert isinstance(public_key, bytes) + return public_key + + @property + def signer(self) -> AccountTransactionSigner: + """An AccountTransactionSigner for this account""" + return AccountTransactionSigner(self.private_key) + + @staticmethod + def new_account() -> "Account": + private_key, address = algosdk.account.generate_account() + return Account(private_key=private_key) diff --git a/src/algokit_utils/models/amount.py b/src/algokit_utils/models/amount.py new file mode 100644 index 00000000..ac86cd3b --- /dev/null +++ b/src/algokit_utils/models/amount.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +from decimal import Decimal + +import algosdk +from typing_extensions import Self + + +class AlgoAmount: + def __init__(self, amount: dict[str, int | Decimal]): + if "microAlgos" in amount: + self.amount_in_micro_algo = int(amount["microAlgos"]) + elif "microAlgo" in amount: + self.amount_in_micro_algo = int(amount["microAlgo"]) + elif "algos" in amount: + self.amount_in_micro_algo = algosdk.util.algos_to_microalgos(float(amount["algos"])) + elif "algo" in amount: + self.amount_in_micro_algo = algosdk.util.algos_to_microalgos(float(amount["algo"])) + else: + raise ValueError("Invalid amount provided") + + @property + def micro_algos(self) -> int: + return self.amount_in_micro_algo + + @property + def micro_algo(self) -> int: + return self.amount_in_micro_algo + + @property + def algos(self) -> int | Decimal: + return algosdk.util.microalgos_to_algos(self.amount_in_micro_algo) # type: ignore[no-any-return] + + @property + def algo(self) -> int | Decimal: + return algosdk.util.microalgos_to_algos(self.amount_in_micro_algo) # type: ignore[no-any-return] + + @staticmethod + def from_algos(amount: int | Decimal) -> AlgoAmount: + return AlgoAmount({"algos": amount}) + + @staticmethod + def from_algo(amount: int | Decimal) -> AlgoAmount: + return AlgoAmount({"algo": amount}) + + @staticmethod + def from_micro_algos(amount: int | Decimal) -> AlgoAmount: + return AlgoAmount({"microAlgos": amount}) + + @staticmethod + def from_micro_algo(amount: int | Decimal) -> AlgoAmount: + return AlgoAmount({"microAlgo": amount}) + + def __str__(self) -> str: + """Return a string representation of the amount.""" + return f"{self.micro_algo:,} µALGO" + + def __int__(self) -> int: + """Return the amount as an integer number of microAlgos.""" + return self.micro_algos + + def __add__(self, other: int | Decimal | AlgoAmount) -> AlgoAmount: + if isinstance(other, AlgoAmount): + total_micro_algos = self.micro_algos + other.micro_algos + elif isinstance(other, (int | Decimal)): + total_micro_algos = self.micro_algos + int(other) + else: + raise TypeError(f"Unsupported operand type(s) for +: 'AlgoAmount' and '{type(other).__name__}'") + return AlgoAmount.from_micro_algos(total_micro_algos) + + def __radd__(self, other: int | Decimal) -> AlgoAmount: + return self.__add__(other) + + def __iadd__(self, other: int | Decimal | AlgoAmount) -> Self: + if isinstance(other, AlgoAmount): + self.amount_in_micro_algo += other.micro_algos + elif isinstance(other, (int | Decimal)): + self.amount_in_micro_algo += int(other) + else: + raise TypeError(f"Unsupported operand type(s) for +: 'AlgoAmount' and '{type(other).__name__}'") + return self + + def __eq__(self, other: object) -> bool: + if isinstance(other, AlgoAmount): + return self.amount_in_micro_algo == other.amount_in_micro_algo + elif isinstance(other, int | Decimal): + return self.amount_in_micro_algo == int(other) + raise TypeError(f"Unsupported operand type(s) for ==: 'AlgoAmount' and '{type(other).__name__}'") + + def __ne__(self, other: object) -> bool: + if isinstance(other, AlgoAmount): + return self.amount_in_micro_algo != other.amount_in_micro_algo + elif isinstance(other, int | Decimal): + return self.amount_in_micro_algo != int(other) + raise TypeError(f"Unsupported operand type(s) for !=: 'AlgoAmount' and '{type(other).__name__}'") + + def __lt__(self, other: object) -> bool: + if isinstance(other, AlgoAmount): + return self.amount_in_micro_algo < other.amount_in_micro_algo + elif isinstance(other, int | Decimal): + return self.amount_in_micro_algo < int(other) + raise TypeError(f"Unsupported operand type(s) for <: 'AlgoAmount' and '{type(other).__name__}'") + + def __le__(self, other: object) -> bool: + if isinstance(other, AlgoAmount): + return self.amount_in_micro_algo <= other.amount_in_micro_algo + elif isinstance(other, int | Decimal): + return self.amount_in_micro_algo <= int(other) + raise TypeError(f"Unsupported operand type(s) for <=: 'AlgoAmount' and '{type(other).__name__}'") + + def __gt__(self, other: object) -> bool: + if isinstance(other, AlgoAmount): + return self.amount_in_micro_algo > other.amount_in_micro_algo + elif isinstance(other, int | Decimal): + return self.amount_in_micro_algo > int(other) + raise TypeError(f"Unsupported operand type(s) for >: 'AlgoAmount' and '{type(other).__name__}'") + + def __ge__(self, other: object) -> bool: + if isinstance(other, AlgoAmount): + return self.amount_in_micro_algo >= other.amount_in_micro_algo + elif isinstance(other, int | Decimal): + return self.amount_in_micro_algo >= int(other) + raise TypeError(f"Unsupported operand type(s) for >=: 'AlgoAmount' and '{type(other).__name__}'") diff --git a/src/algokit_utils/models/application.py b/src/algokit_utils/models/application.py new file mode 100644 index 00000000..c68e78af --- /dev/null +++ b/src/algokit_utils/models/application.py @@ -0,0 +1,5 @@ +UPDATABLE_TEMPLATE_NAME = "TMPL_UPDATABLE" +"""The name of the TEAL template variable for deploy-time immutability control.""" + +DELETABLE_TEMPLATE_NAME = "TMPL_DELETABLE" +"""The name of the TEAL template variable for deploy-time permanence control.""" diff --git a/src/algokit_utils/models/common.py b/src/algokit_utils/models/common.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/algokit_utils/transactions/models.py b/src/algokit_utils/transactions/models.py index e69de29b..251bbf96 100644 --- a/src/algokit_utils/transactions/models.py +++ b/src/algokit_utils/transactions/models.py @@ -0,0 +1,30 @@ +from typing import Any, Literal, TypedDict + + +# Define specific types for different formats +class BaseArc2Note(TypedDict): + """Base ARC-0002 transaction note structure""" + + dapp_name: str + + +class StringFormatArc2Note(BaseArc2Note): + """ARC-0002 note for string-based formats (m/b/u)""" + + format: Literal["m", "b", "u"] + data: str + + +class JsonFormatArc2Note(BaseArc2Note): + """ARC-0002 note for JSON format""" + + format: Literal["j"] + data: str | dict[str, Any] | list[Any] | int | None + + +# Combined type for all valid ARC-0002 notes +# See: https://github.com/algorandfoundation/ARCs/blob/main/ARCs/arc-0002.md +Arc2TransactionNote = StringFormatArc2Note | JsonFormatArc2Note + +TransactionNoteData = str | None | int | list[Any] | dict[str, Any] +TransactionNote = bytes | TransactionNoteData | Arc2TransactionNote diff --git a/src/algokit_utils/transactions/transaction_composer.py b/src/algokit_utils/transactions/transaction_composer.py index 254b2a30..2d36c06d 100644 --- a/src/algokit_utils/transactions/transaction_composer.py +++ b/src/algokit_utils/transactions/transaction_composer.py @@ -1,18 +1,33 @@ -from collections.abc import Callable +from __future__ import annotations + +import math from dataclasses import dataclass -from typing import Union +from typing import TYPE_CHECKING, Union import algosdk -from algosdk.abi import Method +import algosdk.atomic_transaction_composer 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 +from deprecated import deprecated + +from algokit_utils._debugging import simulate_and_persist_response, simulate_response +from algokit_utils.applications.app_manager import AppManager +from algokit_utils.config import config + +if TYPE_CHECKING: + from collections.abc import Callable + + from algosdk.abi import Method + from algosdk.box_reference import BoxReference + from algosdk.v2client.algod import AlgodClient + + from algokit_utils.models.abi import ABIValue + from algokit_utils.models.amount import AlgoAmount + from algokit_utils.transactions.models import Arc2TransactionNote @dataclass(frozen=True) @@ -22,26 +37,44 @@ class SenderParam: @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. + """ + + sender: str 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 + static_fee: AlgoAmount | None = None + extra_fee: AlgoAmount | None = None + max_fee: AlgoAmount | 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): +class _RequiredPaymentParams: receiver: str - amount: int + amount: AlgoAmount @dataclass(frozen=True) -class PayParams(CommonTxnParams, _RequiredPayTxnParams): +class PaymentParams(CommonTxnParams, _RequiredPaymentParams): """ Payment transaction parameters. @@ -54,31 +87,69 @@ class PayParams(CommonTxnParams, _RequiredPayTxnParams): @dataclass(frozen=True) -class _RequiredAssetCreateParams(SenderParam): +class _RequiredAssetCreateParams: total: int + asset_name: str + unit_name: str + url: str @dataclass(frozen=True) -class AssetCreateParams(CommonTxnParams, _RequiredAssetCreateParams): +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): +class _RequiredAssetConfigParams: asset_id: int @dataclass(frozen=True) -class AssetConfigParams(CommonTxnParams, _RequiredAssetConfigParams): +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 @@ -86,14 +157,17 @@ class AssetConfigParams(CommonTxnParams, _RequiredAssetConfigParams): @dataclass(frozen=True) -class _RequiredAssetFreezeParams(SenderParam): +class _RequiredAssetFreezeParams: asset_id: int account: str frozen: bool @dataclass(frozen=True) -class AssetFreezeParams(CommonTxnParams, _RequiredAssetFreezeParams): +class AssetFreezeParams( + CommonTxnParams, + _RequiredAssetFreezeParams, +): """ Asset freeze parameters. @@ -104,12 +178,15 @@ class AssetFreezeParams(CommonTxnParams, _RequiredAssetFreezeParams): @dataclass(frozen=True) -class _RequiredAssetDestroyParams(SenderParam): +class _RequiredAssetDestroyParams: asset_id: int @dataclass(frozen=True) -class AssetDestroyParams(CommonTxnParams, _RequiredAssetDestroyParams): +class AssetDestroyParams( + CommonTxnParams, + _RequiredAssetDestroyParams, +): """ Asset destruction parameters. @@ -118,7 +195,7 @@ class AssetDestroyParams(CommonTxnParams, _RequiredAssetDestroyParams): @dataclass(frozen=True) -class _RequiredOnlineKeyRegParams(SenderParam): +class _RequiredOnlineKeyRegistrationParams: vote_key: str selection_key: str vote_first: int @@ -127,30 +204,63 @@ class _RequiredOnlineKeyRegParams(SenderParam): @dataclass(frozen=True) -class OnlineKeyRegParams(CommonTxnParams, _RequiredOnlineKeyRegParams): +class OnlineKeyRegistrationParams( + CommonTxnParams, + _RequiredOnlineKeyRegistrationParams, +): + """ + 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): +class _RequiredAssetTransferParams: asset_id: int amount: int receiver: str @dataclass(frozen=True) -class AssetTransferParams(CommonTxnParams, _RequiredAssetTransferParams): +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): +class _RequiredAssetOptInParams: asset_id: int @dataclass(frozen=True) -class AssetOptInParams(CommonTxnParams, _RequiredAssetOptInParams): +class AssetOptInParams( + CommonTxnParams, + _RequiredAssetOptInParams, +): """ Asset opt-in parameters. @@ -158,6 +268,22 @@ class AssetOptInParams(CommonTxnParams, _RequiredAssetOptInParams): """ +@dataclass(frozen=True) +class _RequiredAssetOptOutParams: + asset_id: int + creator: str + + +@dataclass(frozen=True) +class AssetOptOutParams( + CommonTxnParams, + _RequiredAssetOptOutParams, +): + """ + Asset opt-out parameters. + """ + + @dataclass(frozen=True) class AppCallParams(CommonTxnParams, SenderParam): """ @@ -166,7 +292,7 @@ class AppCallParams(CommonTxnParams, SenderParam): :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 clear_state_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. @@ -178,8 +304,8 @@ class AppCallParams(CommonTxnParams, SenderParam): on_complete: OnComplete | None = None app_id: int | None = None - approval_program: bytes | None = None - clear_program: bytes | None = None + approval_program: str | bytes | None = None + clear_state_program: str | bytes | None = None schema: dict[str, int] | None = None args: list[bytes] | None = None account_references: list[str] | None = None @@ -190,46 +316,296 @@ class AppCallParams(CommonTxnParams, SenderParam): @dataclass(frozen=True) -class _RequiredMethodCallParams(SenderParam): +class _RequiredAppCreateParams: + approval_program: str | bytes + clear_state_program: str | bytes + + +@dataclass(frozen=True) +class AppCreateParams(CommonTxnParams, SenderParam, _RequiredAppCreateParams): + """ + Application create parameters. + + :param approval_program: The program to execute for all OnCompletes other than ClearState as raw teal (string) + or compiled teal (bytes) + :param clear_state_program: The program to execute for ClearState OnComplete as raw teal (string) + or compiled teal (bytes) + :param schema: The state schema for the app. This is immutable. + :param on_complete: The OnComplete action (cannot be ClearState) + :param args: Application arguments + :param account_references: Account references + :param app_references: App references + :param asset_references: Asset references + :param box_references: Box references + :param extra_program_pages: Number of extra pages required for the programs + """ + + schema: dict[str, int] | None = None + on_complete: OnComplete | 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 + box_references: list[BoxReference] | None = None + extra_program_pages: int | None = None + + +@dataclass(frozen=True) +class _RequiredAppUpdateParams: + app_id: int + approval_program: str | bytes + clear_state_program: str | bytes + + +@dataclass(frozen=True) +class AppUpdateParams(CommonTxnParams, SenderParam, _RequiredAppUpdateParams): + """ + Application update parameters. + + :param app_id: ID of the application + :param approval_program: The program to execute for all OnCompletes other than ClearState as raw teal (string) or + compiled teal (bytes) + :param clear_state_program: The program to execute for ClearState OnComplete as raw teal (string) or compiled + teal (bytes) + """ + + args: list[bytes] | None = None + account_references: list[str] | None = None + app_references: list[int] | None = None + asset_references: list[int] | None = None + box_references: list[BoxReference] | None = None + on_complete: OnComplete | None = None + + +@dataclass(frozen=True) +class _RequiredAppDeleteParams: + app_id: int + + +@dataclass(frozen=True) +class AppDeleteParams( + CommonTxnParams, + SenderParam, + _RequiredAppDeleteParams, +): + """ + Application delete parameters. + + :param app_id: ID of the application + """ + + app_id: int + on_complete: OnComplete = OnComplete.DeleteApplicationOC + + +@dataclass(frozen=True) +class _RequiredMethodCallParams: + app_id: int + method: Method + + +@dataclass(frozen=True) +class AppMethodCall(CommonTxnParams, SenderParam, _RequiredMethodCallParams): + """Base class for ABI method calls.""" + + args: list | None = None + account_references: list[str] | None = None + app_references: list[int] | None = None + asset_references: list[int] | None = None + box_references: list[BoxReference] | None = None + + +@dataclass(frozen=True) +class _RequiredAppMethodCallParams: app_id: int method: Method @dataclass(frozen=True) -class MethodCallParams(CommonTxnParams, _RequiredMethodCallParams): +class AppMethodCallParams(CommonTxnParams, SenderParam, _RequiredAppMethodCallParams): """ Method call parameters. - :param app_id: ID of the application. - :param method: The ABI method to call. - :param args: Arguments to the ABI method. + :param app_id: ID of the application + :param method: The ABI method to call + :param args: Arguments to the ABI method + :param on_complete: The OnComplete action (cannot be UpdateApplication or ClearState) """ - args: list | None = None + args: list[bytes] | None = None + on_complete: OnComplete | None = None + account_references: list[str] | None = None + app_references: list[int] | None = None + asset_references: list[int] | None = None + box_references: list[BoxReference] | None = None + + +@dataclass(frozen=True) +class AppCallMethodCall(AppMethodCall): + """Parameters for a regular ABI method call. + + :param app_id: ID of the application + :param method: The ABI method to call + :param args: Arguments to the ABI method, either: + * An ABI value + * A transaction with explicit signer + * A transaction (where the signer will be automatically assigned) + * Another method call + * None (represents a placeholder transaction argument) + :param on_complete: The OnComplete action (cannot be UpdateApplication or ClearState) + """ + + app_id: int + on_complete: OnComplete | None = None + + +@dataclass(frozen=True) +class _RequiredAppCreateMethodCallParams: + approval_program: str | bytes + clear_state_program: str | bytes + + +@dataclass(frozen=True) +class AppCreateMethodCall(AppMethodCall, _RequiredAppCreateMethodCallParams): + """Parameters for an ABI method call that creates an application. + + :param approval_program: The program to execute for all OnCompletes other than ClearState + :param clear_state_program: The program to execute for ClearState OnComplete + :param schema: The state schema for the app + :param on_complete: The OnComplete action (cannot be ClearState) + :param extra_program_pages: Number of extra pages required for the programs + """ + + schema: dict[str, int] | None = None + on_complete: OnComplete | None = None + extra_program_pages: int | None = None + + +@dataclass(frozen=True) +class _RequiredAppUpdateMethodCallParams: + app_id: int + approval_program: str | bytes + clear_state_program: str | bytes + + +@dataclass(frozen=True) +class AppUpdateMethodCall(AppMethodCall, _RequiredAppUpdateMethodCallParams): + """Parameters for an ABI method call that updates an application. + + :param app_id: ID of the application + :param approval_program: The program to execute for all OnCompletes other than ClearState + :param clear_state_program: The program to execute for ClearState OnComplete + """ + + on_complete: OnComplete = OnComplete.UpdateApplicationOC + + +@dataclass(frozen=True) +class AppDeleteMethodCall(AppMethodCall): + """Parameters for an ABI method call that deletes an application. + + :param app_id: ID of the application + """ + + app_id: int + on_complete: OnComplete = OnComplete.DeleteApplicationOC + + +# Type alias for all possible method call types +MethodCallParams = AppCallMethodCall | AppCreateMethodCall | AppUpdateMethodCall | AppDeleteMethodCall + + +# Type alias for transaction arguments in method calls +AppMethodCallTransactionArgument = ( + TransactionWithSigner + | algosdk.transaction.Transaction + | AppCreateMethodCall + | AppUpdateMethodCall + | AppCallMethodCall +) TxnParams = Union[ # noqa: UP007 - PayParams, + PaymentParams, AssetCreateParams, AssetConfigParams, AssetFreezeParams, AssetDestroyParams, - OnlineKeyRegParams, + OnlineKeyRegistrationParams, AssetTransferParams, AssetOptInParams, + AssetOptOutParams, AppCallParams, + AppCreateParams, + AppUpdateParams, + AppDeleteParams, MethodCallParams, ] +@dataclass +class BuiltTransactions: + """ + Set of transactions built by TransactionComposer. + + :param transactions: The built transactions. + :param method_calls: Any ABIMethod objects associated with any of the transactions in a map keyed by txn id. + :param signers: Any TransactionSigner objects associated with any of the transactions in a map keyed by txn id. + """ + + transactions: list[algosdk.transaction.Transaction] + method_calls: dict[int, Method] + signers: dict[int, TransactionSigner] + + +@dataclass +class TransactionComposerBuildResult: + atc: AtomicTransactionComposer + transactions: list[TransactionWithSigner] + method_calls: dict[int, Method] + + class TransactionComposer: - def __init__( + """ + 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. + """ + + NULL_SIGNER: TransactionSigner = algosdk.atomic_transaction_composer.EmptySigner() + + def __init__( # noqa: PLR0913 self, algod: AlgodClient, get_signer: Callable[[str], TransactionSigner], get_suggested_params: Callable[[], algosdk.transaction.SuggestedParams] | None = None, default_validity_window: int | None = None, + app_manager: AppManager | None = None, ): + """ + Initialize an instance of the TransactionComposer 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() @@ -238,51 +614,195 @@ def __init__( 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 + self.app_manager = app_manager or AppManager(algod) - def add_payment(self, params: PayParams) -> "TransactionComposer": + def add_transaction( + self, transaction: algosdk.transaction.Transaction, signer: TransactionSigner | None = None + ) -> TransactionComposer: + self.txns.append(TransactionWithSigner(txn=transaction, signer=signer or self.get_signer(transaction.sender))) + return self + + def add_payment(self, params: PaymentParams) -> TransactionComposer: self.txns.append(params) return self - def add_asset_create(self, params: AssetCreateParams) -> "TransactionComposer": + def add_asset_create(self, params: AssetCreateParams) -> TransactionComposer: self.txns.append(params) return self - def add_asset_config(self, params: AssetConfigParams) -> "TransactionComposer": + def add_asset_config(self, params: AssetConfigParams) -> TransactionComposer: self.txns.append(params) return self - def add_asset_freeze(self, params: AssetFreezeParams) -> "TransactionComposer": + def add_asset_freeze(self, params: AssetFreezeParams) -> TransactionComposer: self.txns.append(params) return self - def add_asset_destroy(self, params: AssetDestroyParams) -> "TransactionComposer": + def add_asset_destroy(self, params: AssetDestroyParams) -> TransactionComposer: self.txns.append(params) return self - def add_asset_transfer(self, params: AssetTransferParams) -> "TransactionComposer": + def add_asset_transfer(self, params: AssetTransferParams) -> TransactionComposer: self.txns.append(params) return self - def add_asset_opt_in(self, params: AssetOptInParams) -> "TransactionComposer": + def add_asset_opt_in(self, params: AssetOptInParams) -> TransactionComposer: self.txns.append(params) return self - def add_app_call(self, params: AppCallParams) -> "TransactionComposer": + def add_asset_opt_out(self, params: AssetOptOutParams) -> TransactionComposer: self.txns.append(params) return self - def add_online_key_reg(self, params: OnlineKeyRegParams) -> "TransactionComposer": + def add_app_create(self, params: AppCreateParams) -> TransactionComposer: self.txns.append(params) return self - def add_atc(self, atc: AtomicTransactionComposer) -> "TransactionComposer": - self.txns.append(atc) + def add_app_update(self, params: AppUpdateParams) -> TransactionComposer: + self.txns.append(params) return self - def add_method_call(self, params: MethodCallParams) -> "TransactionComposer": + def add_app_delete(self, params: AppDeleteParams) -> TransactionComposer: self.txns.append(params) return self + def add_app_call(self, params: AppCallParams) -> TransactionComposer: + self.txns.append(params) + return self + + def add_app_create_method_call(self, params: AppCreateMethodCall) -> TransactionComposer: + self.txns.append(params) + return self + + def add_app_update_method_call(self, params: AppUpdateMethodCall) -> TransactionComposer: + self.txns.append(params) + return self + + def add_app_delete_method_call(self, params: AppDeleteMethodCall) -> TransactionComposer: + self.txns.append(params) + return self + + def add_app_call_method_call(self, params: AppCallMethodCall) -> TransactionComposer: + self.txns.append(params) + return self + + def add_online_key_registration(self, params: OnlineKeyRegistrationParams) -> TransactionComposer: + self.txns.append(params) + return self + + def add_atc(self, atc: AtomicTransactionComposer) -> TransactionComposer: + self.txns.append(atc) + return self + + def count(self) -> int: + return len(self.build_transactions().transactions) + + def build(self) -> TransactionComposerBuildResult: + if self.atc.get_status() == algosdk.atomic_transaction_composer.AtomicTransactionComposerStatus.BUILDING: + 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 = self.txn_method_map.get(ts.txn.get_txid()) + if method: + self.atc.method_dict[len(self.atc.txn_list) - 1] = method + + return TransactionComposerBuildResult( + atc=self.atc, + transactions=self.atc.build_group(), + method_calls=self.atc.method_dict, + ) + + def rebuild(self) -> TransactionComposerBuildResult: + self.atc = AtomicTransactionComposer() + return self.build() + + def build_transactions(self) -> BuiltTransactions: + suggested_params = self.get_suggested_params() + + transactions: list[algosdk.transaction.Transaction] = [] + method_calls: dict[int, Method] = {} + signers: dict[int, TransactionSigner] = {} + + idx = 0 + + for txn in self.txns: + txn_with_signers: list[TransactionWithSigner] = [] + + if isinstance(txn, MethodCallParams): + txn_with_signers.extend(self._build_method_call(txn, suggested_params)) + else: + txn_with_signers.extend(self._build_txn(txn, suggested_params)) + + for ts in txn_with_signers: + transactions.append(ts.txn) + if ts.signer and ts.signer != self.NULL_SIGNER: + signers[idx] = ts.signer + method = self.txn_method_map.get(ts.txn.get_txid()) + if method: + method_calls[idx] = method + idx += 1 + + return BuiltTransactions(transactions=transactions, method_calls=method_calls, signers=signers) + + @deprecated(reason="Use send() instead", version="3.0.0") + def execute( + self, + *, + max_rounds_to_wait: int | None = None, + ) -> algosdk.atomic_transaction_composer.AtomicTransactionResponse: + return self.send( + max_rounds_to_wait=max_rounds_to_wait, + ) + + def send( + self, + max_rounds_to_wait: int | None = None, + ) -> algosdk.atomic_transaction_composer.AtomicTransactionResponse: + group = self.build().transactions + + 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 + 1 + + try: + return self.atc.execute(client=self.algod, wait_rounds=wait_rounds) # TODO: reimplement ATC + except algosdk.error.AlgodHTTPError as e: + raise Exception(f"Transaction failed: {e}") from e + + def simulate(self) -> algosdk.atomic_transaction_composer.SimulateAtomicTransactionResponse: + if config.debug and config.project_root and config.trace_all: + return simulate_and_persist_response( + self.atc, + config.project_root, + self.algod, + config.trace_buffer_size_mb, + ) + + return simulate_response( + self.atc, + self.algod, + ) + + @staticmethod + def arc2_note(note: Arc2TransactionNote) -> bytes: + """ + Create an encoded transaction note that follows the ARC-2 spec. + + https://github.com/algorandfoundation/ARCs/blob/main/ARCs/arc-0002.md + + :param note: The ARC-2 note to encode. + """ + + arc2_payload = f"{note['dapp_name']}:{note['format']}{note['data']}" + return arc2_payload.encode("utf-8") + def _build_atc(self, atc: AtomicTransactionComposer) -> list[TransactionWithSigner]: group = atc.build_group() @@ -291,7 +811,7 @@ def _build_atc(self, atc: AtomicTransactionComposer) -> list[TransactionWithSign 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] + self.txn_method_map[group[-1].txn.get_txid()] = method return group @@ -304,7 +824,7 @@ def _common_txn_build_step( 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] + txn.rekey_to = algosdk.encoding.decode_address(params.rekey_to) if params.note: txn.note = params.note @@ -320,9 +840,9 @@ def _common_txn_build_step( raise ValueError("Cannot set both static_fee and extra_fee") if params.static_fee is not None: - txn.fee = params.static_fee + txn.fee = params.static_fee.micro_algos else: - txn.fee = txn.estimate_size() * suggested_params.fee or algosdk.constants.min_txn_fee # type: ignore[no-untyped-call] + txn.fee = txn.estimate_size() * suggested_params.fee or algosdk.constants.min_txn_fee if params.extra_fee: txn.fee += params.extra_fee @@ -331,23 +851,95 @@ def _common_txn_build_step( return txn + def _build_method_call( # noqa: C901, PLR0912 + self, params: MethodCallParams, suggested_params: algosdk.transaction.SuggestedParams + ) -> list[TransactionWithSigner]: + method_args: list[ABIValue | TransactionWithSigner] = [] + 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 ( + AppCreateMethodCall() + | AppCallMethodCall() + | AppUpdateMethodCall() + | AppDeleteMethodCall() + ): + 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 PaymentParams(): + 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 OnlineKeyRegistrationParams(): + txn = self._build_key_reg(arg, suggested_params) + case _: + raise ValueError(f"Unsupported method arg transaction type: {arg!s}") + + method_args.append( + TransactionWithSigner(txn=txn, signer=params.signer or self.get_signer(params.sender)) + ) + + continue + + raise ValueError(f"Unsupported method arg: {arg!s}") + + 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, + boxes=[(ref.app_index, ref.name) for ref in params.box_references] if params.box_references else None, + ) + + return self._build_atc(method_atc) + def _build_payment( - self, params: PayParams, suggested_params: algosdk.transaction.SuggestedParams + self, params: PaymentParams, suggested_params: algosdk.transaction.SuggestedParams ) -> algosdk.transaction.Transaction: txn = algosdk.transaction.PaymentTxn( sender=params.sender, sp=suggested_params, receiver=params.receiver, - amt=params.amount, + amt=params.amount.micro_algos, 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( + txn = algosdk.transaction.AssetCreateTxn( sender=params.sender, sp=suggested_params, total=params.total, @@ -361,46 +953,71 @@ def _build_asset_create( 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 + self, + params: AppCallParams | AppUpdateParams | AppCreateParams, + suggested_params: algosdk.transaction.SuggestedParams, ) -> algosdk.transaction.Transaction: + app_id = params.app_id if isinstance(params, AppCallParams | AppUpdateMethodCall) else None + + approval_program = None + clear_program = None + + if isinstance(params, AppUpdateParams | AppCreateParams): + if isinstance(params.approval_program, str): + approval_program = self.app_manager.compile_teal(params.approval_program).compiled_base64_to_bytes + elif isinstance(params.approval_program, bytes): + approval_program = params.approval_program + + if isinstance(params.clear_state_program, str): + clear_program = self.app_manager.compile_teal(params.clear_state_program).compiled_base64_to_bytes + elif isinstance(params.clear_state_program, bytes): + clear_program = params.clear_state_program + + approval_program_len = len(approval_program) if approval_program else 0 + clear_program_len = len(clear_program) if clear_program else 0 + 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, + "on_complete": params.on_complete or algosdk.transaction.OnComplete.NoOpOC, "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, + "boxes": params.box_references, + "approval_program": approval_program, + "clear_program": clear_program, } - if not params.app_id: - if params.approval_program is None or params.clear_program is None: + if not app_id and isinstance(params, AppCreateParams): + if not sdk_params["approval_program"] or not sdk_params["clear_program"]: raise ValueError("approval_program and clear_program are required for application creation") - txn = algosdk.transaction.ApplicationCreateTxn(**sdk_params) # type: ignore[no-untyped-call] + if not params.schema: + raise ValueError("schema is required for application creation") + + txn = algosdk.transaction.ApplicationCreateTxn( + **sdk_params, + global_schema=algosdk.transaction.StateSchema( + num_uints=params.schema.get("global_uints", 0), + num_byte_slices=params.schema.get("global_byte_slices", 0), + ), + local_schema=algosdk.transaction.StateSchema( + num_uints=params.schema.get("local_uints", 0), + num_byte_slices=params.schema.get("local_byte_slices", 0), + ), + extra_pages=params.extra_program_pages + or math.floor((approval_program_len + clear_program_len) / algosdk.constants.APP_PAGE_MAX_SIZE) + if params.extra_program_pages + else 0, + ) else: - txn = algosdk.transaction.ApplicationCallTxn(**sdk_params) # type: ignore[assignment,no-untyped-call] + txn = algosdk.transaction.ApplicationCallTxn(**sdk_params, index=app_id) # type: ignore[assignment] return self._common_txn_build_step(params, txn, suggested_params) @@ -416,7 +1033,7 @@ def _build_asset_config( 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) @@ -427,7 +1044,7 @@ def _build_asset_destroy( 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) @@ -440,7 +1057,7 @@ def _build_asset_freeze( 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) @@ -455,12 +1072,12 @@ def _build_asset_transfer( 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 + self, params: OnlineKeyRegistrationParams, suggested_params: algosdk.transaction.SuggestedParams ) -> algosdk.transaction.Transaction: txn = algosdk.transaction.KeyregTxn( sender=params.sender, @@ -473,7 +1090,7 @@ def _build_key_reg( 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) @@ -483,72 +1100,6 @@ def _is_abi_value(self, x: bool | float | str | bytes | list | TxnParams) -> boo return isinstance(x, bool | int | float | str | bytes) - def _build_method_call( # noqa: C901, PLR0912 - 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( # noqa: C901, PLR0912, PLR0911 self, txn: TransactionWithSigner | TxnParams | AtomicTransactionComposer, @@ -559,19 +1110,19 @@ def _build_txn( # noqa: C901, PLR0912, PLR0911 return [txn] case AtomicTransactionComposer(): return self._build_atc(txn) - case MethodCallParams(): + case AppCreateMethodCall() | AppCallMethodCall() | AppUpdateMethodCall() | AppDeleteMethodCall(): return self._build_method_call(txn, suggested_params) signer = txn.signer or self.get_signer(txn.sender) match txn: - case PayParams(): + case PaymentParams(): 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(): + case AppCallParams() | AppUpdateParams() | AppCreateParams(): app_call = self._build_app_call(txn, suggested_params) return [TransactionWithSigner(txn=app_call, signer=signer)] case AssetConfigParams(): @@ -591,42 +1142,8 @@ def _build_txn( # noqa: C901, PLR0912, PLR0911 AssetTransferParams(**txn.__dict__, receiver=txn.sender, amount=0), suggested_params ) return [TransactionWithSigner(txn=asset_transfer, signer=signer)] - case OnlineKeyRegParams(): + case OnlineKeyRegistrationParams(): 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/transactions/transaction_creator.py b/src/algokit_utils/transactions/transaction_creator.py new file mode 100644 index 00000000..e4ae0e03 --- /dev/null +++ b/src/algokit_utils/transactions/transaction_creator.py @@ -0,0 +1,2 @@ +class AlgorandClientTransactionCreator: + """A creator for Algorand transactions""" diff --git a/src/algokit_utils/transactions/transaction_sender.py b/src/algokit_utils/transactions/transaction_sender.py new file mode 100644 index 00000000..41c5aa4b --- /dev/null +++ b/src/algokit_utils/transactions/transaction_sender.py @@ -0,0 +1,88 @@ +import logging +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, Generic, TypeVar + +from algosdk.transaction import wait_for_confirmation +from algosdk.v2client.algod import AlgodClient + +from algokit_utils.applications.app_manager import AppManager +from algokit_utils.assets.asset_manager import AssetManager +from algokit_utils.transactions.transaction_composer import ( + PaymentParams, + TransactionComposer, +) + +logger = logging.getLogger(__name__) +TxnParam = TypeVar("TxnParam") +TxnResult = TypeVar("TxnResult") + + +@dataclass +class SendTransactionResult(Generic[TxnResult]): + """Result of sending a transaction""" + + confirmation: dict[str, Any] + tx_id: str + return_value: TxnResult | None = None + + +class AlgorandClientTransactionSender: + """Orchestrates sending transactions for AlgorandClient.""" + + def __init__( + self, + new_group: Callable[[], TransactionComposer], + asset_manager: AssetManager, + app_manager: AppManager, + algod_client: AlgodClient, + ) -> None: + self._new_group = new_group + self._asset_manager = asset_manager + self._app_manager = app_manager + self._algod_client = algod_client + + def new_group(self) -> TransactionComposer: + """Create a new transaction group""" + return self._new_group() + + def _send( + self, + c: Callable[[TransactionComposer], Callable[[TxnParam], TransactionComposer]], + log: dict[str, Callable[[TxnParam, Any], str]] | None = None, + ) -> Callable[[TxnParam], SendTransactionResult[Any]]: + """Generic method to send transactions with logging.""" + + def send_transaction(params: TxnParam) -> SendTransactionResult[Any]: + composer = self._new_group() + c(composer)(params) + + if log and log.get("pre_log"): + transaction = composer.build().transactions[-1].txn + logger.debug(log["pre_log"](params, transaction)) + + result = composer.send() + + if log and log.get("post_log"): + logger.debug(log["post_log"](params, result)) + + confirmation = wait_for_confirmation(self._algod_client, result.tx_ids[0]) + return SendTransactionResult( + confirmation=confirmation, + tx_id=result.tx_ids[0], + ) + + return send_transaction + + @property + def payment(self) -> Callable[[PaymentParams], SendTransactionResult[None]]: + """Send a payment transaction""" + return self._send( + lambda c: c.add_payment, + { + "pre_log": lambda params, txn: ( + f"Sending {params.amount.micro_algos} µALGO from {params.sender} " + f"to {params.receiver} via transaction {txn.get_txid()}" + ) + }, + ) diff --git a/src/algokit_utils/accounts/models.py b/tests/applications/__init__.py similarity index 100% rename from src/algokit_utils/accounts/models.py rename to tests/applications/__init__.py diff --git a/tests/applications/snapshots/test_app_manager.approvals/test_comment_stripping.approved.txt b/tests/applications/snapshots/test_app_manager.approvals/test_comment_stripping.approved.txt new file mode 100644 index 00000000..3795ccbf --- /dev/null +++ b/tests/applications/snapshots/test_app_manager.approvals/test_comment_stripping.approved.txt @@ -0,0 +1,30 @@ + + +op arg +op "arg" +op "//" +op " //comment " +op "\" //" +op "// \" //" +op "" + +op 123 +op 123 +op "" +op "//" +op "//" +pushbytes base64(//8=) +pushbytes b64(//8=) + +pushbytes base64(//8=) +pushbytes b64(//8=) +pushbytes "base64(//8=)" +pushbytes "b64(//8=)" + +pushbytes base64 //8= +pushbytes b64 //8= + +pushbytes base64 //8= +pushbytes b64 //8= +pushbytes "base64 //8=" +pushbytes "b64 //8=" diff --git a/tests/applications/snapshots/test_app_manager.approvals/test_template_substitution.approved.txt b/tests/applications/snapshots/test_app_manager.approvals/test_template_substitution.approved.txt new file mode 100644 index 00000000..6cbde085 --- /dev/null +++ b/tests/applications/snapshots/test_app_manager.approvals/test_template_substitution.approved.txt @@ -0,0 +1,21 @@ + +test 123 // TMPL_INT +test 123 +no change +test 0x414243 // TMPL_STR +0x414243 +0x414243 // TMPL_INT +0x414243 // foo // +0x414243 // bar +test "TMPL_STR" // not replaced +test "TMPL_STRING" // not replaced +test TMPL_STRING // not replaced +test TMPL_STRI // not replaced +test 0x414243 123 123 0x414243 // TMPL_STR TMPL_INT TMPL_INT TMPL_STR +test 123 0x414243 TMPL_STRING "TMPL_INT TMPL_STR TMPL_STRING" //TMPL_INT TMPL_STR TMPL_STRING +test 123 123 TMPL_STRING TMPL_STRING TMPL_STRING 123 TMPL_STRING //keep +0x414243 0x414243 0x414243 +TMPL_STRING +test NOTTMPL_STR // not replaced +NOTTMPL_STR // not replaced +0x414243 // replaced \ No newline at end of file diff --git a/tests/applications/test_app_manager.py b/tests/applications/test_app_manager.py new file mode 100644 index 00000000..8c9c1002 --- /dev/null +++ b/tests/applications/test_app_manager.py @@ -0,0 +1,77 @@ +import pytest +from algokit_utils.applications.app_manager import AppManager +from algokit_utils.clients.algorand_client import AlgorandClient +from algokit_utils.models.account import Account + +from tests.conftest import check_output_stability + + +@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 + + +def test_template_substitution() -> None: + program = """ +test TMPL_INT // TMPL_INT +test TMPL_INT +no change +test TMPL_STR // TMPL_STR +TMPL_STR +TMPL_STR // TMPL_INT +TMPL_STR // foo // +TMPL_STR // bar +test "TMPL_STR" // not replaced +test "TMPL_STRING" // not replaced +test TMPL_STRING // not replaced +test TMPL_STRI // not replaced +test TMPL_STR TMPL_INT TMPL_INT TMPL_STR // TMPL_STR TMPL_INT TMPL_INT TMPL_STR +test TMPL_INT TMPL_STR TMPL_STRING "TMPL_INT TMPL_STR TMPL_STRING" //TMPL_INT TMPL_STR TMPL_STRING +test TMPL_INT TMPL_INT TMPL_STRING TMPL_STRING TMPL_STRING TMPL_INT TMPL_STRING //keep +TMPL_STR TMPL_STR TMPL_STR +TMPL_STRING +test NOTTMPL_STR // not replaced +NOTTMPL_STR // not replaced +TMPL_STR // replaced +""" + result = AppManager.replace_template_variables(program, {"INT": 123, "STR": "ABC"}) + check_output_stability(result) + + +def test_comment_stripping() -> None: + program = r""" +//comment +op arg //comment +op "arg" //comment +op "//" //comment +op " //comment " //comment +op "\" //" //comment +op "// \" //" //comment +op "" //comment +// +op 123 +op 123 // something +op "" // more comments +op "//" //op "//" +op "//" +pushbytes base64(//8=) +pushbytes b64(//8=) + +pushbytes base64(//8=) // pushbytes base64(//8=) +pushbytes b64(//8=) // pushbytes b64(//8=) +pushbytes "base64(//8=)" // pushbytes "base64(//8=)" +pushbytes "b64(//8=)" // pushbytes "b64(//8=)" + +pushbytes base64 //8= +pushbytes b64 //8= + +pushbytes base64 //8= // pushbytes base64 //8= +pushbytes b64 //8= // pushbytes b64 //8= +pushbytes "base64 //8=" // pushbytes "base64 //8=" +pushbytes "b64 //8=" // pushbytes "b64 //8=" + +""" + result = AppManager.strip_teal_comments(program) + check_output_stability(result) diff --git a/src/algokit_utils/applications/models.py b/tests/clients/__init__.py similarity index 100% rename from src/algokit_utils/applications/models.py rename to tests/clients/__init__.py diff --git a/tests/clients/test_algorand_client.py b/tests/clients/test_algorand_client.py new file mode 100644 index 00000000..ce0f90d5 --- /dev/null +++ b/tests/clients/test_algorand_client.py @@ -0,0 +1,223 @@ +# TODO: Update tests for latest version of algokit-utils +# import json +# from pathlib import Path + +# import pytest +# from algokit_utils import Account, ApplicationClient +# from algokit_utils.accounts.account_manager import AddressAndSigner +# from algokit_utils.clients.algorand_client import ( +# AlgorandClient, +# AppMethodCallParams, +# AssetCreateParams, +# AssetOptInParams, +# PaymentParams, +# ) +# 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(PaymentParams(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(PaymentParams(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(PaymentParams(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(PaymentParams(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(PaymentParams(sender=alice.address, amount=0, receiver=alice.address)) +# .add_method_call( +# AppMethodCallParams( +# 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 = PaymentParams(sender=alice.address, receiver=alice.address, amount=1) +# result = ( +# algorand.new_group() +# .add_payment(PaymentParams(sender=alice.address, amount=0, receiver=alice.address)) +# .add_method_call( +# AppMethodCallParams( +# 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 = AppMethodCallParams( +# method=contract.get_method_by_name("helloWorld"), sender=alice.address, app_id=app_client.app_id +# ) +# result = ( +# algorand.new_group() +# .add_method_call( +# AppMethodCallParams( +# 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 = PaymentParams(sender=alice.address, receiver=alice.address, amount=1) +# txn_arg_call = AppMethodCallParams( +# 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( +# AppMethodCallParams( +# 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 = PaymentParams(sender=alice.address, receiver=alice.address, amount=1) +# txn_arg_call_1 = AppMethodCallParams( +# 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 = PaymentParams(sender=alice.address, receiver=alice.address, amount=2) +# txn_arg_call_2 = AppMethodCallParams( +# 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( +# AppMethodCallParams( +# 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 diff --git a/tests/conftest.py b/tests/conftest.py index e3997a2c..18021c21 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -45,7 +45,7 @@ def check_output_stability(logs: str, *, test_name: str | None = None) -> None: caller_dir = caller_path.parent test_name = test_name or caller_frame.function caller_stem = Path(caller_frame.filename).stem - output_dir = caller_dir / f"{caller_stem}.approvals" + output_dir = caller_dir / "snapshots" / f"{caller_stem}.approvals" output_dir.mkdir(exist_ok=True) output_file = output_dir / f"{test_name}.approved.txt" output_file_str = str(output_file) @@ -188,11 +188,11 @@ def generate_test_asset(algod_client: "AlgodClient", sender: Account, total: int note=None, lease=None, rekey_to=None, - ) # type: ignore[no-untyped-call] + ) - signed_transaction = txn.sign(sender.private_key) # type: ignore[no-untyped-call] + signed_transaction = txn.sign(sender.private_key) algod_client.send_transaction(signed_transaction) - ptx = algod_client.pending_transaction_info(txn.get_txid()) # type: ignore[no-untyped-call] + ptx = algod_client.pending_transaction_info(txn.get_txid()) if isinstance(ptx, dict) and "asset-index" in ptx and isinstance(ptx["asset-index"], int): return ptx["asset-index"] diff --git a/tests/test_algorand_client.py b/tests/test_algorand_client.py deleted file mode 100644 index 8b7c448d..00000000 --- a/tests/test_algorand_client.py +++ /dev/null @@ -1,222 +0,0 @@ -import json -from pathlib import Path - -import pytest -from algokit_utils import Account, ApplicationClient -from algokit_utils.accounts.account_manager import AddressAndSigner -from algokit_utils.clients.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 diff --git a/tests/test_transaction_composer.py b/tests/test_transaction_composer.py new file mode 100644 index 00000000..1cbab861 --- /dev/null +++ b/tests/test_transaction_composer.py @@ -0,0 +1,212 @@ +from typing import TYPE_CHECKING + +import pytest +from algokit_utils._legacy_v2.account import get_account +from algokit_utils.clients.algorand_client import AlgorandClient +from algokit_utils.models.account import Account +from algokit_utils.models.amount import AlgoAmount +from algokit_utils.transactions.transaction_composer import ( + AppCreateParams, + AssetConfigParams, + AssetCreateParams, + PaymentParams, + TransactionComposer, +) +from algosdk.atomic_transaction_composer import ( + AtomicTransactionResponse, +) +from algosdk.transaction import ( + ApplicationCreateTxn, + AssetConfigTxn, + AssetCreateTxn, + PaymentTxn, +) + +from legacy_v2_tests.conftest import get_unique_name + +if TYPE_CHECKING: + from algokit_utils.transactions.models import Arc2TransactionNote + + +@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 funded_secondary_account(algorand: AlgorandClient) -> Account: + secondary_name = get_unique_name() + return get_account(algorand.client.algod, secondary_name) + + +def test_add_transaction(algorand: AlgorandClient, funded_account: Account) -> None: + composer = TransactionComposer( + algod=algorand.client.algod, + get_signer=lambda _: funded_account.signer, + ) + txn = PaymentTxn( + sender=funded_account.address, + sp=algorand.client.algod.suggested_params(), + receiver=funded_account.address, + amt=AlgoAmount.from_algos(1).micro_algos, + ) + composer.add_transaction(txn) + built = composer.build_transactions() + + assert len(built.transactions) == 1 + assert isinstance(built.transactions[0], PaymentTxn) + assert built.transactions[0].sender == funded_account.address + assert built.transactions[0].receiver == funded_account.address + assert built.transactions[0].amt == AlgoAmount.from_algos(1).micro_algos + + +def test_add_asset_create(algorand: AlgorandClient, funded_account: Account) -> None: + composer = TransactionComposer( + algod=algorand.client.algod, + get_signer=lambda _: funded_account.signer, + ) + expected_total = 1000 + params = AssetCreateParams( + sender=funded_account.address, + total=expected_total, + decimals=0, + default_frozen=False, + unit_name="TEST", + asset_name="Test Asset", + url="https://example.com", + ) + + composer.add_asset_create(params) + built = composer.build_transactions() + response = composer.execute(max_rounds_to_wait=20) + created_asset = algorand.client.algod.asset_info( + algorand.client.algod.pending_transaction_info(response.tx_ids[0])["asset-index"] # type: ignore[call-overload] + )["params"] + + assert len(response.tx_ids) == 1 + assert response.confirmed_round > 0 + assert isinstance(built.transactions[0], AssetCreateTxn) + txn = built.transactions[0] + assert txn.sender == funded_account.address + assert created_asset["creator"] == funded_account.address + assert txn.total == created_asset["total"] == expected_total + assert txn.decimals == created_asset["decimals"] == 0 + assert txn.default_frozen == created_asset["default-frozen"] is False + assert txn.unit_name == created_asset["unit-name"] == "TEST" + assert txn.asset_name == created_asset["name"] == "Test Asset" + + +def test_add_asset_config(algorand: AlgorandClient, funded_account: Account, funded_secondary_account: Account) -> None: + # First create an asset + asset_txn = AssetCreateTxn( + sender=funded_account.address, + sp=algorand.client.algod.suggested_params(), + total=1000, + decimals=0, + default_frozen=False, + unit_name="CFG", + asset_name="Configurable Asset", + manager=funded_account.address, + ) + signed_asset_txn = asset_txn.sign(funded_account.signer.private_key) + tx_id = algorand.client.algod.send_transaction(signed_asset_txn) + asset_before_config = algorand.client.algod.asset_info( + algorand.client.algod.pending_transaction_info(tx_id)["asset-index"] # type: ignore[call-overload] + ) + asset_before_config_index = asset_before_config["index"] # type: ignore[call-overload] + + composer = TransactionComposer( + algod=algorand.client.algod, + get_signer=lambda _: funded_account.signer, + ) + params = AssetConfigParams( + sender=funded_account.address, + asset_id=asset_before_config_index, + manager=funded_secondary_account.address, + ) + composer.add_asset_config(params) + built = composer.build_transactions() + + assert len(built.transactions) == 1 + assert isinstance(built.transactions[0], AssetConfigTxn) + txn = built.transactions[0] + assert txn.sender == funded_account.address + assert txn.index == asset_before_config_index + assert txn.manager == funded_secondary_account.address + + composer.execute(max_rounds_to_wait=20) + updated_asset = algorand.client.algod.asset_info(asset_id=asset_before_config_index)["params"] # type: ignore[call-overload] + assert updated_asset["manager"] == funded_secondary_account.address + + +def test_add_app_create(algorand: AlgorandClient, funded_account: Account) -> None: + composer = TransactionComposer( + algod=algorand.client.algod, + get_signer=lambda _: funded_account.signer, + ) + approval_program = "#pragma version 6\nint 1" + clear_state_program = "#pragma version 6\nint 1" + params = AppCreateParams( + sender=funded_account.address, + approval_program=approval_program, + clear_state_program=clear_state_program, + schema={"global_ints": 0, "global_bytes": 0, "local_ints": 0, "local_bytes": 0}, + ) + composer.add_app_create(params) + built = composer.build_transactions() + + assert len(built.transactions) == 1 + assert isinstance(built.transactions[0], ApplicationCreateTxn) + txn = built.transactions[0] + assert txn.sender == funded_account.address + assert txn.approval_program == b"\x06\x81\x01" + assert txn.clear_program == b"\x06\x81\x01" + composer.execute(max_rounds_to_wait=20) + + +def test_simulate(algorand: AlgorandClient, funded_account: Account) -> None: + composer = TransactionComposer( + algod=algorand.client.algod, + get_signer=lambda _: funded_account.signer, + ) + composer.add_payment( + PaymentParams( + sender=funded_account.address, + receiver=funded_account.address, + amount=AlgoAmount.from_algos(1), + ) + ) + composer.build() + simulate_response = composer.simulate() + assert simulate_response + + +def test_send(algorand: AlgorandClient, funded_account: Account) -> None: + composer = TransactionComposer( + algod=algorand.client.algod, + get_signer=lambda _: funded_account.signer, + ) + composer.add_payment( + PaymentParams( + sender=funded_account.address, + receiver=funded_account.address, + amount=AlgoAmount.from_algos(1), + ) + ) + response = composer.send() + assert isinstance(response, AtomicTransactionResponse) + assert len(response.tx_ids) == 1 + assert response.confirmed_round > 0 + + +def test_arc2_note() -> None: + note_data: Arc2TransactionNote = { + "dapp_name": "TestDApp", + "format": "j", + "data": '{"key":"value"}', + } + encoded_note = TransactionComposer.arc2_note(note_data) + expected_note = b'TestDApp:j{"key":"value"}' + assert encoded_note == expected_note diff --git a/src/algokit_utils/assets/models.py b/tests/transactions/__init__.py similarity index 100% rename from src/algokit_utils/assets/models.py rename to tests/transactions/__init__.py diff --git a/tests/transactions/artifacts/hello_world/approval.teal b/tests/transactions/artifacts/hello_world/approval.teal new file mode 100644 index 00000000..d38f6432 --- /dev/null +++ b/tests/transactions/artifacts/hello_world/approval.teal @@ -0,0 +1,62 @@ +#pragma version 10 + +smart_contracts.hello_world.contract.HelloWorld.approval_program: + intcblock 0 1 + callsub __puya_arc4_router__ + return + + +// smart_contracts.hello_world.contract.HelloWorld.__puya_arc4_router__() -> uint64: +__puya_arc4_router__: + proto 0 1 + txn NumAppArgs + bz __puya_arc4_router___bare_routing@5 + pushbytes 0x02bece11 // method "hello(string)string" + txna ApplicationArgs 0 + match __puya_arc4_router___hello_route@2 + intc_0 // 0 + retsub + +__puya_arc4_router___hello_route@2: + txn OnCompletion + ! + assert // OnCompletion is NoOp + txn ApplicationID + assert // is not creating + txna ApplicationArgs 1 + extract 2 0 + callsub hello + dup + len + itob + extract 6 2 + swap + concat + pushbytes 0x151f7c75 + swap + concat + log + intc_1 // 1 + retsub + +__puya_arc4_router___bare_routing@5: + txn OnCompletion + bnz __puya_arc4_router___after_if_else@9 + txn ApplicationID + ! + assert // is creating + intc_1 // 1 + retsub + +__puya_arc4_router___after_if_else@9: + intc_0 // 0 + retsub + + +// smart_contracts.hello_world.contract.HelloWorld.hello(name: bytes) -> bytes: +hello: + proto 1 1 + pushbytes "Hello, " + frame_dig -1 + concat + retsub diff --git a/tests/transactions/artifacts/hello_world/clear.teal b/tests/transactions/artifacts/hello_world/clear.teal new file mode 100644 index 00000000..5a70c80b --- /dev/null +++ b/tests/transactions/artifacts/hello_world/clear.teal @@ -0,0 +1,5 @@ +#pragma version 10 + +smart_contracts.hello_world.contract.HelloWorld.clear_state_program: + pushint 1 // 1 + return diff --git a/tests/transactions/test_transaction_composer.py b/tests/transactions/test_transaction_composer.py new file mode 100644 index 00000000..0a75961a --- /dev/null +++ b/tests/transactions/test_transaction_composer.py @@ -0,0 +1,256 @@ +from pathlib import Path +from typing import TYPE_CHECKING + +import algosdk +import pytest +from algokit_utils._legacy_v2.account import get_account +from algokit_utils.clients.algorand_client import AlgorandClient +from algokit_utils.models.account import Account +from algokit_utils.models.amount import AlgoAmount +from algokit_utils.transactions.transaction_composer import ( + AppCallMethodCall, + AppCreateParams, + AssetConfigParams, + AssetCreateParams, + PaymentParams, + TransactionComposer, +) +from algosdk.atomic_transaction_composer import ( + AtomicTransactionResponse, +) +from algosdk.transaction import ( + ApplicationCallTxn, + ApplicationCreateTxn, + AssetConfigTxn, + AssetCreateTxn, + PaymentTxn, +) + +from legacy_v2_tests.conftest import get_unique_name + +if TYPE_CHECKING: + from algokit_utils.transactions.models import Arc2TransactionNote + + +@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 funded_secondary_account(algorand: AlgorandClient) -> Account: + secondary_name = get_unique_name() + return get_account(algorand.client.algod, secondary_name) + + +def test_add_transaction(algorand: AlgorandClient, funded_account: Account) -> None: + composer = TransactionComposer( + algod=algorand.client.algod, + get_signer=lambda _: funded_account.signer, + ) + txn = PaymentTxn( + sender=funded_account.address, + sp=algorand.client.algod.suggested_params(), + receiver=funded_account.address, + amt=AlgoAmount.from_algos(1).micro_algos, + ) + composer.add_transaction(txn) + built = composer.build_transactions() + + assert len(built.transactions) == 1 + assert isinstance(built.transactions[0], PaymentTxn) + assert built.transactions[0].sender == funded_account.address + assert built.transactions[0].receiver == funded_account.address + assert built.transactions[0].amt == AlgoAmount.from_algos(1).micro_algos + + +def test_add_asset_create(algorand: AlgorandClient, funded_account: Account) -> None: + composer = TransactionComposer( + algod=algorand.client.algod, + get_signer=lambda _: funded_account.signer, + ) + expected_total = 1000 + params = AssetCreateParams( + sender=funded_account.address, + total=expected_total, + decimals=0, + default_frozen=False, + unit_name="TEST", + asset_name="Test Asset", + url="https://example.com", + ) + + composer.add_asset_create(params) + built = composer.build_transactions() + response = composer.execute(max_rounds_to_wait=20) + created_asset = algorand.client.algod.asset_info( + algorand.client.algod.pending_transaction_info(response.tx_ids[0])["asset-index"] # type: ignore[call-overload] + )["params"] + + assert len(response.tx_ids) == 1 + assert response.confirmed_round > 0 + assert isinstance(built.transactions[0], AssetCreateTxn) + txn = built.transactions[0] + assert txn.sender == funded_account.address + assert created_asset["creator"] == funded_account.address + assert txn.total == created_asset["total"] == expected_total + assert txn.decimals == created_asset["decimals"] == 0 + assert txn.default_frozen == created_asset["default-frozen"] is False + assert txn.unit_name == created_asset["unit-name"] == "TEST" + assert txn.asset_name == created_asset["name"] == "Test Asset" + + +def test_add_asset_config(algorand: AlgorandClient, funded_account: Account, funded_secondary_account: Account) -> None: + # First create an asset + asset_txn = AssetCreateTxn( + sender=funded_account.address, + sp=algorand.client.algod.suggested_params(), + total=1000, + decimals=0, + default_frozen=False, + unit_name="CFG", + asset_name="Configurable Asset", + manager=funded_account.address, + ) + signed_asset_txn = asset_txn.sign(funded_account.signer.private_key) + tx_id = algorand.client.algod.send_transaction(signed_asset_txn) + asset_before_config = algorand.client.algod.asset_info( + algorand.client.algod.pending_transaction_info(tx_id)["asset-index"] # type: ignore[call-overload] + ) + asset_before_config_index = asset_before_config["index"] # type: ignore[call-overload] + + composer = TransactionComposer( + algod=algorand.client.algod, + get_signer=lambda _: funded_account.signer, + ) + params = AssetConfigParams( + sender=funded_account.address, + asset_id=asset_before_config_index, + manager=funded_secondary_account.address, + ) + composer.add_asset_config(params) + built = composer.build_transactions() + + assert len(built.transactions) == 1 + assert isinstance(built.transactions[0], AssetConfigTxn) + txn = built.transactions[0] + assert txn.sender == funded_account.address + assert txn.index == asset_before_config_index + assert txn.manager == funded_secondary_account.address + + composer.execute(max_rounds_to_wait=20) + updated_asset = algorand.client.algod.asset_info(asset_id=asset_before_config_index)["params"] # type: ignore[call-overload] + assert updated_asset["manager"] == funded_secondary_account.address + + +def test_add_app_create(algorand: AlgorandClient, funded_account: Account) -> None: + composer = TransactionComposer( + algod=algorand.client.algod, + get_signer=lambda _: funded_account.signer, + ) + approval_program = "#pragma version 6\nint 1" + clear_state_program = "#pragma version 6\nint 1" + params = AppCreateParams( + sender=funded_account.address, + approval_program=approval_program, + clear_state_program=clear_state_program, + schema={"global_ints": 0, "global_bytes": 0, "local_ints": 0, "local_bytes": 0}, + ) + composer.add_app_create(params) + built = composer.build_transactions() + + assert len(built.transactions) == 1 + assert isinstance(built.transactions[0], ApplicationCreateTxn) + txn = built.transactions[0] + assert txn.sender == funded_account.address + assert txn.approval_program == b"\x06\x81\x01" + assert txn.clear_program == b"\x06\x81\x01" + composer.execute(max_rounds_to_wait=20) + + +def test_add_app_call_method_call(algorand: AlgorandClient, funded_account: Account) -> None: + composer = TransactionComposer( + algod=algorand.client.algod, + get_signer=lambda _: funded_account.signer, + ) + approval_program = Path(Path(__file__).parent / "artifacts" / "hello_world" / "approval.teal").read_text() + clear_state_program = Path(Path(__file__).parent / "artifacts" / "hello_world" / "clear.teal").read_text() + composer.add_app_create( + AppCreateParams( + sender=funded_account.address, + approval_program=approval_program, + clear_state_program=clear_state_program, + schema={"global_ints": 0, "global_bytes": 0, "local_ints": 0, "local_bytes": 0}, + ) + ) + response = composer.execute() + app_id = algorand.client.algod.pending_transaction_info(response.tx_ids[0])["application-index"] # type: ignore[call-overload] + + composer = TransactionComposer( + algod=algorand.client.algod, + get_signer=lambda _: funded_account.signer, + ) + composer.add_app_call_method_call( + AppCallMethodCall( + sender=funded_account.address, + app_id=app_id, + method=algosdk.abi.Method.from_signature("hello(string)string"), + args=["world"], + ) + ) + built = composer.build_transactions() + + assert len(built.transactions) == 1 + assert isinstance(built.transactions[0], ApplicationCallTxn) + txn = built.transactions[0] + assert txn.sender == funded_account.address + response = composer.execute(max_rounds_to_wait=20) + assert response.abi_results[0].return_value == "Hello, world" + + +def test_simulate(algorand: AlgorandClient, funded_account: Account) -> None: + composer = TransactionComposer( + algod=algorand.client.algod, + get_signer=lambda _: funded_account.signer, + ) + composer.add_payment( + PaymentParams( + sender=funded_account.address, + receiver=funded_account.address, + amount=AlgoAmount.from_algos(1), + ) + ) + composer.build() + simulate_response = composer.simulate() + assert simulate_response + + +def test_send(algorand: AlgorandClient, funded_account: Account) -> None: + composer = TransactionComposer( + algod=algorand.client.algod, + get_signer=lambda _: funded_account.signer, + ) + composer.add_payment( + PaymentParams( + sender=funded_account.address, + receiver=funded_account.address, + amount=AlgoAmount.from_algos(1), + ) + ) + response = composer.send() + assert isinstance(response, AtomicTransactionResponse) + assert len(response.tx_ids) == 1 + assert response.confirmed_round > 0 + + +def test_arc2_note() -> None: + note_data: Arc2TransactionNote = { + "dapp_name": "TestDApp", + "format": "j", + "data": '{"key":"value"}', + } + encoded_note = TransactionComposer.arc2_note(note_data) + expected_note = b'TestDApp:j{"key":"value"}' + assert encoded_note == expected_note