From f4b201de7b1bcf61e4f5cde1f918ac9180971371 Mon Sep 17 00:00:00 2001 From: Altynbek Orumbayev Date: Fri, 1 Nov 2024 18:55:01 +0100 Subject: [PATCH] feat: initial implementation of TransactionComposer --- legacy_v2_tests/conftest.py | 6 +- pyproject.toml | 1 + src/algokit_utils/assets/asset_manager.py | 2 + src/algokit_utils/clients/algorand_client.py | 60 +- src/algokit_utils/models/amount.py | 60 +- src/algokit_utils/transactions/models.py | 30 + .../transactions/transaction_composer.py | 1260 ++++++++++------- .../transactions/transaction_creator.py | 2 + .../transactions/transaction_sender.py | 88 ++ tests/applications/__init__.py | 0 .../test_comment_stripping.approved.txt | 0 .../test_template_substitution.approved.txt | 0 tests/{ => applications}/test_app_manager.py | 0 tests/clients/__init__.py | 0 tests/clients/test_algorand_client.py | 223 +++ tests/test_algorand_client.py | 222 --- tests/test_transaction_composer.py | 212 +++ tests/transactions/__init__.py | 0 .../artifacts/hello_world/approval.teal | 62 + .../artifacts/hello_world/clear.teal | 5 + .../transactions/test_transaction_composer.py | 256 ++++ 21 files changed, 1707 insertions(+), 782 deletions(-) create mode 100644 src/algokit_utils/assets/asset_manager.py create mode 100644 src/algokit_utils/transactions/transaction_creator.py create mode 100644 src/algokit_utils/transactions/transaction_sender.py create mode 100644 tests/applications/__init__.py rename tests/{ => applications}/snapshots/test_app_manager.approvals/test_comment_stripping.approved.txt (100%) rename tests/{ => applications}/snapshots/test_app_manager.approvals/test_template_substitution.approved.txt (100%) rename tests/{ => applications}/test_app_manager.py (100%) create mode 100644 tests/clients/__init__.py 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 create mode 100644 tests/transactions/__init__.py 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 fa2506c8..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"] 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 8c2e8ec3..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, @@ -31,6 +33,8 @@ PaymentParams, TransactionComposer, ) +from algokit_utils.transactions.transaction_creator import AlgorandClientTransactionCreator +from algokit_utils.transactions.transaction_sender import AlgorandClientTransactionSender __all__ = [ "AlgorandClient", @@ -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/models/amount.py b/src/algokit_utils/models/amount.py index 7f880ac9..ac86cd3b 100644 --- a/src/algokit_utils/models/amount.py +++ b/src/algokit_utils/models/amount.py @@ -3,10 +3,11 @@ from decimal import Decimal import algosdk +from typing_extensions import Self class AlgoAmount: - def __init__(self, amount: dict[str, float | int | Decimal]): + def __init__(self, amount: dict[str, int | Decimal]): if "microAlgos" in amount: self.amount_in_micro_algo = int(amount["microAlgos"]) elif "microAlgo" in amount: @@ -27,67 +28,96 @@ def micro_algo(self) -> int: return self.amount_in_micro_algo @property - def algos(self) -> Decimal: + 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) -> Decimal: + 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: float | Decimal) -> AlgoAmount: + def from_algos(amount: int | Decimal) -> AlgoAmount: return AlgoAmount({"algos": amount}) @staticmethod - def from_algo(amount: float | Decimal) -> AlgoAmount: + def from_algo(amount: int | Decimal) -> AlgoAmount: return AlgoAmount({"algo": amount}) @staticmethod - def from_micro_algos(amount: int) -> AlgoAmount: + def from_micro_algos(amount: int | Decimal) -> AlgoAmount: return AlgoAmount({"microAlgos": amount}) @staticmethod - def from_micro_algo(amount: int) -> AlgoAmount: + 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 NotImplementedError + 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)): + elif isinstance(other, int | Decimal): return self.amount_in_micro_algo != int(other) - raise NotImplementedError + 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)): + elif isinstance(other, int | Decimal): return self.amount_in_micro_algo < int(other) - raise NotImplementedError + 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 NotImplementedError + 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 NotImplementedError + 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 NotImplementedError + raise TypeError(f"Unsupported operand type(s) for >=: 'AlgoAmount' and '{type(other).__name__}'") 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 e072e613..2d36c06d 100644 --- a/src/algokit_utils/transactions/transaction_composer.py +++ b/src/algokit_utils/transactions/transaction_composer.py @@ -2,27 +2,16 @@ import math from dataclasses import dataclass -from typing import ( - TYPE_CHECKING, - Any, - TypeVar, -) +from typing import TYPE_CHECKING, Union import algosdk import algosdk.atomic_transaction_composer -from algosdk import encoding from algosdk.atomic_transaction_composer import ( AtomicTransactionComposer, - EmptySigner, TransactionSigner, TransactionWithSigner, ) -from algosdk.constants import MIN_TXN_FEE -from algosdk.transaction import ( - OnComplete, - SuggestedParams, - Transaction, -) +from algosdk.transaction import OnComplete from deprecated import deprecated from algokit_utils._debugging import simulate_and_persist_response, simulate_response @@ -36,7 +25,9 @@ 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) @@ -45,7 +36,24 @@ class SenderParam: @dataclass(frozen=True) -class CommonTransactionParams: +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 @@ -60,42 +68,88 @@ class CommonTransactionParams: @dataclass(frozen=True) -class _RequiredPaymentTxnParams(SenderParam): +class _RequiredPaymentParams: receiver: str amount: AlgoAmount @dataclass(frozen=True) -class PaymentParams(CommonTransactionParams, _RequiredPaymentTxnParams): +class PaymentParams(CommonTxnParams, _RequiredPaymentParams): + """ + Payment transaction parameters. + + :param receiver: The account that will receive the ALGO. + :param amount: Amount to send. + :param close_remainder_to: If given, close the sender account and send the remaining balance to this address. + """ + close_remainder_to: str | None = None @dataclass(frozen=True) -class _RequiredAssetCreateParams(SenderParam): +class _RequiredAssetCreateParams: total: int + asset_name: str + unit_name: str + url: str @dataclass(frozen=True) -class AssetCreateParams(CommonTransactionParams, _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(CommonTransactionParams, _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 @@ -103,210 +157,469 @@ class AssetConfigParams(CommonTransactionParams, _RequiredAssetConfigParams): @dataclass(frozen=True) -class _RequiredAssetFreezeParams(SenderParam): +class _RequiredAssetFreezeParams: asset_id: int account: str frozen: bool @dataclass(frozen=True) -class AssetFreezeParams(CommonTransactionParams, _RequiredAssetFreezeParams): - pass +class AssetFreezeParams( + CommonTxnParams, + _RequiredAssetFreezeParams, +): + """ + Asset freeze parameters. + + :param asset_id: The ID of the asset. + :param account: The account to freeze or unfreeze. + :param frozen: Whether the assets in the account should be frozen. + """ @dataclass(frozen=True) -class _RequiredAssetDestroyParams(SenderParam): +class _RequiredAssetDestroyParams: asset_id: int @dataclass(frozen=True) -class AssetDestroyParams(CommonTransactionParams, _RequiredAssetDestroyParams): - pass +class AssetDestroyParams( + CommonTxnParams, + _RequiredAssetDestroyParams, +): + """ + Asset destruction parameters. + + :param asset_id: ID of the asset. + """ + + +@dataclass(frozen=True) +class _RequiredOnlineKeyRegistrationParams: + vote_key: str + selection_key: str + vote_first: int + vote_last: int + vote_key_dilution: int + + +@dataclass(frozen=True) +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(CommonTransactionParams, _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(CommonTransactionParams, _RequiredAssetOptInParams): - pass +class AssetOptInParams( + CommonTxnParams, + _RequiredAssetOptInParams, +): + """ + Asset opt-in parameters. + + :param asset_id: ID of the asset. + """ @dataclass(frozen=True) -class _RequiredAssetOptOutParams(SenderParam): +class _RequiredAssetOptOutParams: asset_id: int creator: str @dataclass(frozen=True) -class AssetOptOutParams(CommonTransactionParams, _RequiredAssetOptOutParams): - pass +class AssetOptOutParams( + CommonTxnParams, + _RequiredAssetOptOutParams, +): + """ + Asset opt-out parameters. + """ @dataclass(frozen=True) -class _RequiredOnlineKeyRegParams(SenderParam): - vote_key: str - selection_key: str - vote_first: int - vote_last: int - vote_key_dilution: int +class AppCallParams(CommonTxnParams, SenderParam): + """ + Application call parameters. + + :param on_complete: The OnComplete action. + :param app_id: ID of the application. + :param approval_program: The program to execute for all OnCompletes other than ClearState. + :param clear_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. + :param app_references: App references. + :param asset_references: Asset references. + :param extra_pages: Number of extra pages required for the programs. + :param box_references: Box references. + """ + + on_complete: OnComplete | None = None + app_id: int | None = None + approval_program: 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 + app_references: list[int] | None = None + asset_references: list[int] | None = None + extra_pages: int | None = None + box_references: list[BoxReference] | None = None @dataclass(frozen=True) -class OnlineKeyRegParams(CommonTransactionParams, _RequiredOnlineKeyRegParams): - state_proof_key: bytes | None = None +class _RequiredAppCreateParams: + approval_program: str | bytes + clear_state_program: str | bytes @dataclass(frozen=True) -class CommonAppCallParams(CommonTransactionParams, SenderParam): - app_id: int | None = None +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 _RequiredAppCreateParams: - approval_program: bytearray | str - clear_program: bytearray | str +class _RequiredAppUpdateParams: + app_id: int + approval_program: str | bytes + clear_state_program: str | bytes @dataclass(frozen=True) -class _AppCreateSchema: - global_ints: int - global_bytes: int - local_ints: int - local_bytes: int +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) + """ -@dataclass(frozen=True) -class AppCreateParams(CommonAppCallParams, _RequiredAppCreateParams): + 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 - schema: _AppCreateSchema | None = None - extra_program_pages: int | None = None @dataclass(frozen=True) -class _RequiredAppUpdateParams: - approval_program: bytearray | str - clear_program: bytearray | str +class _RequiredAppDeleteParams: + app_id: int @dataclass(frozen=True) -class AppUpdateParams(CommonAppCallParams, _RequiredAppUpdateParams): - on_complete: OnComplete | None = None +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 AppCallParams(CommonAppCallParams): +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 on_complete: The OnComplete action (cannot be UpdateApplication or ClearState) + """ + + 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 AppDeleteParams(CommonAppCallParams): +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 AppMethodCallParams(CommonAppCallParams): +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 -T = TypeVar("T", bound=CommonAppCallParams) +@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 + """ -class AppCreateMethodCall(AppMethodCallParams): - pass + on_complete: OnComplete = OnComplete.UpdateApplicationOC -class AppDeleteMethodCall(AppMethodCallParams): - pass +@dataclass(frozen=True) +class AppDeleteMethodCall(AppMethodCall): + """Parameters for an ABI method call that deletes an application. + :param app_id: ID of the application + """ -class AppUpdateMethodCall(AppMethodCallParams): - pass + app_id: int + on_complete: OnComplete = OnComplete.DeleteApplicationOC -class AppCallMethodCall(AppMethodCallParams): - pass +# Type alias for all possible method call types +MethodCallParams = AppCallMethodCall | AppCreateMethodCall | AppUpdateMethodCall | AppDeleteMethodCall -TxnParams = ( - PaymentParams - | AssetCreateParams - | AssetConfigParams - | AssetFreezeParams - | AssetDestroyParams - | OnlineKeyRegParams - | AssetTransferParams - | AssetOptInParams - | AssetOptOutParams - | AppCallParams - | TransactionWithSigner +# Type alias for transaction arguments in method calls +AppMethodCallTransactionArgument = ( + TransactionWithSigner + | algosdk.transaction.Transaction | AppCreateMethodCall | AppUpdateMethodCall - | AppDeleteMethodCall | AppCallMethodCall ) +TxnParams = Union[ # noqa: UP007 + PaymentParams, + AssetCreateParams, + AssetConfigParams, + AssetFreezeParams, + AssetDestroyParams, + 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 transaction index. - :param signers: Any TransactionSigner objects associated with any of the transactions in a map keyed by transaction index. + :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[Transaction] + 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: - NULL_SIGNER: TransactionSigner = EmptySigner() + """ + 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[[], SuggestedParams] | None = None, + 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() self.algod: AlgodClient = algod - self.get_suggested_params = get_suggested_params or algod.suggested_params + self.default_get_send_params = lambda: self.algod.suggested_params() + self.get_suggested_params = get_suggested_params or self.default_get_send_params self.get_signer: Callable[[str], TransactionSigner] = get_signer self.default_validity_window: int = default_validity_window or 10 - self.default_validity_window_is_explicit = default_validity_window is not None - self.txns: list[TransactionWithSigner | TxnParams | AtomicTransactionComposer] = [] - self.atc: AtomicTransactionComposer = AtomicTransactionComposer() - self.txn_method_map: dict[str, Method] = {} self.app_manager = app_manager or AppManager(algod) - def add_transaction(self, transaction: Transaction, signer: TransactionSigner | None = None) -> TransactionComposer: - signer = signer or self.get_signer(algosdk.encoding.encode_address(transaction.sender)) - self.txns.append(TransactionWithSigner(transaction, signer)) + 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: @@ -341,11 +654,19 @@ def add_asset_opt_out(self, params: AssetOptOutParams) -> TransactionComposer: self.txns.append(params) return self - def add_app_call(self, params: AppCallParams) -> TransactionComposer: + def add_app_create(self, params: AppCreateParams) -> TransactionComposer: + self.txns.append(params) + return self + + def add_app_update(self, params: AppUpdateParams) -> TransactionComposer: + self.txns.append(params) + return self + + def add_app_delete(self, params: AppDeleteParams) -> TransactionComposer: self.txns.append(params) return self - def add_online_key_reg(self, params: OnlineKeyRegParams) -> TransactionComposer: + def add_app_call(self, params: AppCallParams) -> TransactionComposer: self.txns.append(params) return self @@ -365,39 +686,145 @@ def add_app_call_method_call(self, params: AppCallMethodCall) -> TransactionComp 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 _build_atc( + 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, - atc: AtomicTransactionComposer, - process_transaction: Callable[[Transaction, int], Transaction] | None = None, - ) -> list[TransactionWithSigner]: + *, + 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() - txn_with_signers: list[TransactionWithSigner] = [] - for idx, ts in enumerate(group): + for ts in group: ts.txn.group = None - if process_transaction: - ts.txn = process_transaction(ts.txn, idx) - method = atc.method_dict.get(len(group) - 1) - if method: - self.txn_method_map[ts.txn.get_txid()] = method - txn_with_signers.append(ts) - return txn_with_signers + method = atc.method_dict.get(len(group) - 1) + if method: + self.txn_method_map[group[-1].txn.get_txid()] = method + + return group def _common_txn_build_step( self, - params: CommonTransactionParams | AppMethodCallParams, - txn: Transaction, - suggested_params: SuggestedParams, - ) -> Transaction: + params: CommonTxnParams, + txn: algosdk.transaction.Transaction, + suggested_params: algosdk.transaction.SuggestedParams, + ) -> algosdk.transaction.Transaction: if params.lease: txn.lease = params.lease if params.rekey_to: - txn.rekey_to = encoding.decode_address(params.rekey_to) + txn.rekey_to = algosdk.encoding.decode_address(params.rekey_to) if params.note: txn.note = params.note @@ -413,99 +840,129 @@ 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 MIN_TXN_FEE) + txn.fee = txn.estimate_size() * suggested_params.fee or algosdk.constants.min_txn_fee if params.extra_fee: - txn.fee += params.extra_fee.micro_algos + txn.fee += params.extra_fee if params.max_fee is not None and txn.fee > params.max_fee: raise ValueError(f"Transaction fee {txn.fee} is greater than max_fee {params.max_fee}") return txn - def _build_payment(self, params: PaymentParams, suggested_params: SuggestedParams) -> Transaction: - txn = algosdk.transaction.PaymentTxn( - sender=params.sender, - sp=suggested_params, - receiver=params.receiver, - amt=params.amount, - close_remainder_to=params.close_remainder_to, - ) + 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 - return self._common_txn_build_step(params, txn, suggested_params) + if params.args: + for i, arg in enumerate(params.args): + if self._is_abi_value(arg): + method_args.append(arg) + continue - def _build_asset_create(self, params: AssetCreateParams, suggested_params: SuggestedParams) -> Transaction: - txn = algosdk.transaction.AssetConfigTxn( - sender=params.sender, - sp=suggested_params, - total=params.total, - default_frozen=params.default_frozen or False, - unit_name=params.unit_name, - asset_name=params.asset_name, - manager=params.manager, - reserve=params.reserve, - freeze=params.freeze, - clawback=params.clawback, - url=params.url, - metadata_hash=params.metadata_hash, - decimals=params.decimals or 0, - strict_empty_address_check=False, - ) + 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}") - return self._common_txn_build_step(params, txn, suggested_params) + method_args.append( + TransactionWithSigner(txn=txn, signer=params.signer or self.get_signer(params.sender)) + ) - def _build_asset_config(self, params: AssetConfigParams, suggested_params: SuggestedParams) -> Transaction: - txn = algosdk.transaction.AssetConfigTxn( - sender=params.sender, - sp=suggested_params, - index=params.asset_id, - manager=params.manager, - reserve=params.reserve, - freeze=params.freeze, - clawback=params.clawback, - strict_empty_address_check=False, - ) + continue - return self._common_txn_build_step(params, txn, suggested_params) + raise ValueError(f"Unsupported method arg: {arg!s}") - def _build_asset_destroy(self, params: AssetDestroyParams, suggested_params: SuggestedParams) -> Transaction: - txn = algosdk.transaction.AssetDestroyTxn( + method_atc = AtomicTransactionComposer() + + method_atc.add_method_call( + app_id=params.app_id or 0, + method=params.method, sender=params.sender, sp=suggested_params, - index=params.asset_id, + 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._common_txn_build_step(params, txn, suggested_params) + return self._build_atc(method_atc) - def _build_asset_freeze(self, params: AssetFreezeParams, suggested_params: SuggestedParams) -> Transaction: - txn = algosdk.transaction.AssetFreezeTxn( + def _build_payment( + self, params: PaymentParams, suggested_params: algosdk.transaction.SuggestedParams + ) -> algosdk.transaction.Transaction: + txn = algosdk.transaction.PaymentTxn( sender=params.sender, sp=suggested_params, - index=params.asset_id, - target=params.account, - new_freeze_state=params.frozen, + receiver=params.receiver, + amt=params.amount.micro_algos, + close_remainder_to=params.close_remainder_to, ) return self._common_txn_build_step(params, txn, suggested_params) - def _build_asset_transfer(self, params: AssetTransferParams, suggested_params: SuggestedParams) -> Transaction: - txn = algosdk.transaction.AssetTransferTxn( + def _build_asset_create( + self, params: AssetCreateParams, suggested_params: algosdk.transaction.SuggestedParams + ) -> algosdk.transaction.Transaction: + txn = algosdk.transaction.AssetCreateTxn( sender=params.sender, sp=suggested_params, - receiver=params.receiver, - amt=params.amount, - index=params.asset_id, - close_assets_to=params.close_asset_to, - revocation_target=params.clawback_target, + total=params.total, + default_frozen=params.default_frozen or False, + unit_name=params.unit_name, + asset_name=params.asset_name, + manager=params.manager, + reserve=params.reserve, + freeze=params.freeze, + clawback=params.clawback, + url=params.url, + metadata_hash=params.metadata_hash, + decimals=params.decimals or 0, ) return self._common_txn_build_step(params, txn, suggested_params) def _build_app_call( - self, params: AppCallParams | AppUpdateParams | AppCreateParams, suggested_params: SuggestedParams - ) -> Transaction: - app_id = params.app_id or None + 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 @@ -516,10 +973,10 @@ def _build_app_call( elif isinstance(params.approval_program, bytes): approval_program = params.approval_program - if isinstance(params.clear_program, str): - clear_program = self.app_manager.compile_teal(params.clear_program).compiled_base64_to_bytes - elif isinstance(params.clear_program, bytes): - clear_program = params.clear_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 @@ -528,7 +985,7 @@ def _build_app_call( "sender": params.sender, "sp": suggested_params, "app_args": params.args, - "on_complete": params.on_complete or OnComplete.NoOpOC, + "on_complete": params.on_complete or algosdk.transaction.OnComplete.NoOpOC, "accounts": params.account_references, "foreign_apps": params.app_references, "foreign_assets": params.asset_references, @@ -541,347 +998,152 @@ def _build_app_call( if not sdk_params["approval_program"] or not sdk_params["clear_program"]: raise ValueError("approval_program and clear_program are required for application creation") + 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, index=app_id) # 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) - def _build_key_reg(self, params: OnlineKeyRegParams, suggested_params: SuggestedParams) -> Transaction: - txn = algosdk.transaction.KeyregTxn( + def _build_asset_config( + self, params: AssetConfigParams, suggested_params: algosdk.transaction.SuggestedParams + ) -> algosdk.transaction.Transaction: + txn = algosdk.transaction.AssetConfigTxn( sender=params.sender, sp=suggested_params, - votekey=params.vote_key, - selkey=params.selection_key, - votefst=params.vote_first, - votelst=params.vote_last, - votekd=params.vote_key_dilution, - rekey_to=params.rekey_to, - nonpart=False, - sprfkey=params.state_proof_key, + index=params.asset_id, + manager=params.manager, + reserve=params.reserve, + freeze=params.freeze, + clawback=params.clawback, + strict_empty_address_check=False, ) return self._common_txn_build_step(params, txn, suggested_params) - def _is_abi_value(self, x: object) -> bool: - if isinstance(x, bool | int | float | str | bytes): - return True - if isinstance(x, list): - return all(self._is_abi_value(item) for item in x) - return False - - def _build_method_call( # noqa: C901, PLR0912 - self, - *, - params: AppCallMethodCall | AppCreateMethodCall | AppUpdateMethodCall, - suggested_params: SuggestedParams, - include_signer: bool, - ) -> list[TransactionWithSigner]: - method_args: list[Any] = [] - 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): - if isinstance(arg, AppCallMethodCall | AppCreateMethodCall | AppUpdateMethodCall): - temp_txn_with_signers = self._build_method_call( - params=arg, - suggested_params=suggested_params, - include_signer=include_signer, - ) - method_args.extend(temp_txn_with_signers) - arg_offset += len(temp_txn_with_signers) - 1 - continue - if isinstance(arg, AppCallParams): - txn = self._build_app_call(arg, suggested_params) - elif isinstance(arg, PaymentParams): - txn = self._build_payment(arg, suggested_params) - elif isinstance(arg, AssetOptInParams): - txn = self._build_asset_transfer( - AssetTransferParams(**arg.__dict__, receiver=arg.sender, amount=0, signer=arg.signer), - suggested_params, - ) - elif isinstance(arg, AssetCreateParams): - txn = self._build_asset_create(arg, suggested_params) - elif isinstance(arg, AssetConfigParams): - txn = self._build_asset_config(arg, suggested_params) - elif isinstance(arg, AssetDestroyParams): - txn = self._build_asset_destroy(arg, suggested_params) - elif isinstance(arg, AssetFreezeParams): - txn = self._build_asset_freeze(arg, suggested_params) - elif isinstance(arg, AssetTransferParams): - txn = self._build_asset_transfer(arg, suggested_params) - elif isinstance(arg, OnlineKeyRegParams): - txn = self._build_key_reg(arg, suggested_params) - else: - raise ValueError(f"Unsupported method arg transaction type: {arg}") - - method_args.append( - TransactionWithSigner( - txn=txn, - signer=( - arg.signer.signer - if isinstance(arg.signer, TransactionSignerAccount) - else arg.signer - if arg.signer - else self.get_signer(arg.sender) - if include_signer - else self.NULL_SIGNER - ), - ) - ) - - 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, + def _build_asset_destroy( + self, params: AssetDestroyParams, suggested_params: algosdk.transaction.SuggestedParams + ) -> algosdk.transaction.Transaction: + txn = algosdk.transaction.AssetDestroyTxn( sender=params.sender, sp=suggested_params, - signer=( - params.signer.signer - if isinstance(params.signer, TransactionSignerAccount) - else params.signer - if params.signer - else self.get_signer(params.sender) - if include_signer - else self.NULL_SIGNER - ), - method_args=method_args, - on_complete=OnComplete.NoOpOC, - note=params.note, - lease=params.lease, - ) - - return self._build_atc( - method_atc, - process_transaction=lambda txn, idx: self._common_txn_build_step(params, txn, suggested_params) - if idx == method_atc.get_tx_count() - 1 - else txn, + index=params.asset_id, ) - def _build_txn( # noqa: C901, PLR0912, PLR0911 - self, - txn: TransactionWithSigner | TxnParams | AtomicTransactionComposer, - suggested_params: SuggestedParams, - ) -> list[TransactionWithSigner]: - if isinstance(txn, TransactionWithSigner): - return [txn] - - if isinstance(txn, AtomicTransactionComposer): - return self._build_atc(txn) - - if isinstance(txn, AppCallMethodCall | AppCreateMethodCall | AppUpdateMethodCall): - return self._build_method_call(params=txn, suggested_params=suggested_params, include_signer=True) + return self._common_txn_build_step(params, txn, suggested_params) - signer = ( - txn.signer - if isinstance(txn.signer, TransactionSignerAccount) - else txn.signer - if txn.signer - else self.get_signer(txn.sender) + def _build_asset_freeze( + self, params: AssetFreezeParams, suggested_params: algosdk.transaction.SuggestedParams + ) -> algosdk.transaction.Transaction: + txn = algosdk.transaction.AssetFreezeTxn( + sender=params.sender, + sp=suggested_params, + index=params.asset_id, + target=params.account, + new_freeze_state=params.frozen, ) - if isinstance(txn, PaymentParams): - payment = self._build_payment(txn, suggested_params) - return [TransactionWithSigner(txn=payment, signer=signer)] - elif isinstance(txn, AssetCreateParams): - asset_create = self._build_asset_create(txn, suggested_params) - return [TransactionWithSigner(txn=asset_create, signer=signer)] - elif isinstance(txn, AppCallParams): - app_call = self._build_app_call(txn, suggested_params) - return [TransactionWithSigner(txn=app_call, signer=signer)] - elif isinstance(txn, AssetConfigParams): - asset_config = self._build_asset_config(txn, suggested_params) - return [TransactionWithSigner(txn=asset_config, signer=signer)] - elif isinstance(txn, AssetDestroyParams): - asset_destroy = self._build_asset_destroy(txn, suggested_params) - return [TransactionWithSigner(txn=asset_destroy, signer=signer)] - elif isinstance(txn, AssetFreezeParams): - asset_freeze = self._build_asset_freeze(txn, suggested_params) - return [TransactionWithSigner(txn=asset_freeze, signer=signer)] - elif isinstance(txn, AssetTransferParams): - asset_transfer = self._build_asset_transfer(txn, suggested_params) - return [TransactionWithSigner(txn=asset_transfer, signer=signer)] - elif isinstance(txn, AssetOptInParams): - asset_transfer = self._build_asset_transfer( - AssetTransferParams(**txn.__dict__, receiver=txn.sender, amount=0), suggested_params - ) - return [TransactionWithSigner(txn=asset_transfer, signer=signer)] - elif isinstance(txn, AssetOptOutParams): - asset_transfer = self._build_asset_transfer( - AssetTransferParams( - **txn.__dict__, - receiver=txn.sender, - amount=0, - close_asset_to=txn.creator, - ), - suggested_params, - ) - return [TransactionWithSigner(txn=asset_transfer, signer=signer)] - elif isinstance(txn, OnlineKeyRegParams): - key_reg = self._build_key_reg(txn, suggested_params) - return [TransactionWithSigner(txn=key_reg, signer=signer)] - else: - raise ValueError(f"Unsupported txn: {txn}") - - def build_transactions(self) -> BuiltTransactions: - suggested_params = self.get_suggested_params() - - transactions: list[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, - TransactionWithSigner - | AtomicTransactionComposer - | AppCallMethodCall - | AppCreateMethodCall - | AppUpdateMethodCall, - ): - txn_with_signers.extend(self._build_txn(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) - - def count(self) -> int: - return len(self.build_transactions().transactions) - - def build(self) -> dict[str, Any]: - 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 { - "atc": self.atc, - "transactions": self.atc.build_group(), - "method_calls": self.atc.method_dict, - } - - def rebuild(self) -> dict[str, Any]: - self.atc = AtomicTransactionComposer() - return self.build() - - 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()) - if method: - method_calls[i] = method - - self.atc.method_dict = method_calls - - return self.atc.build_group() + return self._common_txn_build_step(params, txn, suggested_params) - @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 _build_asset_transfer( + self, params: AssetTransferParams, suggested_params: algosdk.transaction.SuggestedParams + ) -> algosdk.transaction.Transaction: + txn = algosdk.transaction.AssetTransferTxn( + sender=params.sender, + sp=suggested_params, + receiver=params.receiver, + amt=params.amount, + index=params.asset_id, + close_assets_to=params.close_asset_to, + revocation_target=params.clawback_target, ) - 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 self._common_txn_build_step(params, txn, suggested_params) - return simulate_response( - self.atc, - self.algod, + def _build_key_reg( + self, params: OnlineKeyRegistrationParams, suggested_params: algosdk.transaction.SuggestedParams + ) -> algosdk.transaction.Transaction: + txn = algosdk.transaction.KeyregTxn( + sender=params.sender, + sp=suggested_params, + votekey=params.vote_key, + selkey=params.selection_key, + votefst=params.vote_first, + votelst=params.vote_last, + votekd=params.vote_key_dilution, + rekey_to=params.rekey_to, + nonpart=False, + sprfkey=params.state_proof_key, ) - @staticmethod - def arc2_note(dapp_name: str, format_type: str, data: str | dict[str, Any]) -> bytes: - """ - Create an encoded transaction note that follows the ARC-2 spec. - - https://github.com/algorandfoundation/ARCs/blob/main/ARCs/arc-0002.md + return self._common_txn_build_step(params, txn, suggested_params) - :param dapp_name: The name of the dApp. - :param format_type: The format of the data ('j' for JSON, 't' for text). - :param data: The data to include in the note. - :return: The binary encoded transaction note. - """ - if isinstance(data, dict): - import json + def _is_abi_value(self, x: bool | float | str | bytes | list | TxnParams) -> bool: + if isinstance(x, list): + return len(x) == 0 or all(self._is_abi_value(item) for item in x) - data_str = json.dumps(data) - else: - data_str = data + return isinstance(x, bool | int | float | str | bytes) - arc2_payload = f"{dapp_name}:{format_type}{data_str}" - return arc2_payload.encode("utf-8") + def _build_txn( # noqa: C901, PLR0912, PLR0911 + self, + txn: TransactionWithSigner | TxnParams | AtomicTransactionComposer, + suggested_params: algosdk.transaction.SuggestedParams, + ) -> list[TransactionWithSigner]: + match txn: + case TransactionWithSigner(): + return [txn] + case AtomicTransactionComposer(): + return self._build_atc(txn) + case AppCreateMethodCall() | AppCallMethodCall() | AppUpdateMethodCall() | AppDeleteMethodCall(): + return self._build_method_call(txn, suggested_params) + + signer = txn.signer or self.get_signer(txn.sender) + + match txn: + 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() | AppUpdateParams() | AppCreateParams(): + app_call = self._build_app_call(txn, suggested_params) + return [TransactionWithSigner(txn=app_call, signer=signer)] + case AssetConfigParams(): + asset_config = self._build_asset_config(txn, suggested_params) + return [TransactionWithSigner(txn=asset_config, signer=signer)] + case AssetDestroyParams(): + asset_destroy = self._build_asset_destroy(txn, suggested_params) + return [TransactionWithSigner(txn=asset_destroy, signer=signer)] + case AssetFreezeParams(): + asset_freeze = self._build_asset_freeze(txn, suggested_params) + return [TransactionWithSigner(txn=asset_freeze, signer=signer)] + case AssetTransferParams(): + asset_transfer = self._build_asset_transfer(txn, suggested_params) + return [TransactionWithSigner(txn=asset_transfer, signer=signer)] + case AssetOptInParams(): + asset_transfer = self._build_asset_transfer( + AssetTransferParams(**txn.__dict__, receiver=txn.sender, amount=0), suggested_params + ) + return [TransactionWithSigner(txn=asset_transfer, signer=signer)] + case OnlineKeyRegistrationParams(): + key_reg = self._build_key_reg(txn, suggested_params) + return [TransactionWithSigner(txn=key_reg, signer=signer)] + case _: + raise ValueError(f"Unsupported txn: {txn}") 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/tests/applications/__init__.py b/tests/applications/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/snapshots/test_app_manager.approvals/test_comment_stripping.approved.txt b/tests/applications/snapshots/test_app_manager.approvals/test_comment_stripping.approved.txt similarity index 100% rename from tests/snapshots/test_app_manager.approvals/test_comment_stripping.approved.txt rename to tests/applications/snapshots/test_app_manager.approvals/test_comment_stripping.approved.txt diff --git a/tests/snapshots/test_app_manager.approvals/test_template_substitution.approved.txt b/tests/applications/snapshots/test_app_manager.approvals/test_template_substitution.approved.txt similarity index 100% rename from tests/snapshots/test_app_manager.approvals/test_template_substitution.approved.txt rename to tests/applications/snapshots/test_app_manager.approvals/test_template_substitution.approved.txt diff --git a/tests/test_app_manager.py b/tests/applications/test_app_manager.py similarity index 100% rename from tests/test_app_manager.py rename to tests/applications/test_app_manager.py diff --git a/tests/clients/__init__.py b/tests/clients/__init__.py new file mode 100644 index 00000000..e69de29b 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/test_algorand_client.py b/tests/test_algorand_client.py deleted file mode 100644 index a7994bbc..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, - 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/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/tests/transactions/__init__.py b/tests/transactions/__init__.py new file mode 100644 index 00000000..e69de29b 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