diff --git a/src/algokit_utils/__init__.py b/src/algokit_utils/__init__.py index 412470b..3e440ea 100644 --- a/src/algokit_utils/__init__.py +++ b/src/algokit_utils/__init__.py @@ -3,6 +3,7 @@ transfer, ) from algokit_utils.account import ( + create_kmd_wallet_account, get_account, get_account_from_mnemonic, get_dispenser_account, @@ -10,28 +11,20 @@ get_or_create_kmd_wallet_account, get_sandbox_default_account, ) -from algokit_utils.app import ( - DELETABLE_TEMPLATE_NAME, - NOTE_PREFIX, - UPDATABLE_TEMPLATE_NAME, - AppDeployMetaData, - AppLookup, - AppMetaData, - AppReference, - DeploymentFailedError, - get_creator_apps, - replace_template_variables, -) from algokit_utils.application_client import ( ABICallArgs, - ABITransactionResponse, + ABICallArgsDict, + ABICreateCallArgs, + ABICreateCallArgsDict, ApplicationClient, - DeployResponse, - OnSchemaBreak, - OnUpdate, - OperationPerformed, + CommonCallParameters, + CommonCallParametersDict, + CreateCallParameters, + CreateCallParametersDict, + OnCompleteCallParameters, + OnCompleteCallParametersDict, Program, - TransactionResponse, + execute_atc_with_logic_error, get_app_id_from_tx_id, get_next_version, num_extra_program_pages, @@ -46,8 +39,24 @@ MethodHints, OnCompleteActionName, ) +from algokit_utils.deploy import ( + DELETABLE_TEMPLATE_NAME, + NOTE_PREFIX, + UPDATABLE_TEMPLATE_NAME, + AppDeployMetaData, + AppLookup, + AppMetaData, + AppReference, + DeploymentFailedError, + DeployResponse, + OnSchemaBreak, + OnUpdate, + OperationPerformed, + get_creator_apps, + replace_template_variables, +) from algokit_utils.logic_error import LogicError -from algokit_utils.models import Account +from algokit_utils.models import ABITransactionResponse, Account, TransactionResponse from algokit_utils.network_clients import ( AlgoClientConfig, get_algod_client, @@ -57,6 +66,7 @@ ) __all__ = [ + "create_kmd_wallet_account", "get_account_from_mnemonic", "get_or_create_kmd_wallet_account", "get_sandbox_default_account", @@ -74,14 +84,23 @@ "get_creator_apps", "replace_template_variables", "ABICallArgs", - "ABITransactionResponse", + "ABICallArgsDict", + "ABICreateCallArgs", + "ABICreateCallArgsDict", + "ApplicationClient", + "CommonCallParameters", + "CommonCallParametersDict", + "CreateCallParameters", + "CreateCallParametersDict", + "OnCompleteCallParameters", + "OnCompleteCallParametersDict", "ApplicationClient", "DeployResponse", "OnUpdate", "OnSchemaBreak", "OperationPerformed", "Program", - "TransactionResponse", + "execute_atc_with_logic_error", "get_app_id_from_tx_id", "get_next_version", "num_extra_program_pages", @@ -94,7 +113,9 @@ "OnCompleteActionName", "MethodHints", "LogicError", + "ABITransactionResponse", "Account", + "TransactionResponse", "AlgoClientConfig", "get_algod_client", "get_indexer_client", diff --git a/src/algokit_utils/_transfer.py b/src/algokit_utils/_transfer.py index 6ca1680..ab65d7b 100644 --- a/src/algokit_utils/_transfer.py +++ b/src/algokit_utils/_transfer.py @@ -1,7 +1,10 @@ import dataclasses import logging -from algosdk.transaction import PaymentTxn +import algosdk.transaction +from algosdk.account import address_from_private_key +from algosdk.atomic_transaction_composer import AccountTransactionSigner +from algosdk.transaction import PaymentTxn, SuggestedParams from algosdk.v2client.algod import AlgodClient from algokit_utils.models import Account @@ -12,30 +15,63 @@ @dataclasses.dataclass(kw_only=True) class TransferParameters: - from_account: Account + from_account: Account | AccountTransactionSigner + """The account (with private key) or signer that will send the µALGOs""" to_address: str - amount: int - note: str | None = None - max_fee_in_algos: float | None = None + """The account address that will receive the µALGOs""" + micro_algos: int + """The amount of µALGOs to send""" + suggested_params: SuggestedParams | None = None + """(optional) transaction parameters""" + note: str | bytes | None = None + """(optional) transaction note""" + fee_micro_algos: int | None = None + """(optional) The flat fee you want to pay, useful for covering extra fees in a transaction group or app call""" + max_fee_micro_algos: int | None = None + """(optional)The maximum fee that you are happy to pay (default: unbounded) - + if this is set it's possible the transaction could get rejected during network congestion""" -def transfer(transfer_parameters: TransferParameters, client: AlgodClient) -> tuple[PaymentTxn, str]: - suggested_params = client.suggested_params() +def _check_fee(transaction: PaymentTxn, max_fee: int | None) -> None: + if max_fee is not None: + # Once a transaction has been constructed by algosdk, transaction.fee indicates what the total transaction fee + # Will be based on the current suggested fee-per-byte value. + if transaction.fee > max_fee: + raise Exception( + f"Cancelled transaction due to high network congestion fees. " + f"Algorand suggested fees would cause this transaction to cost {transaction.fee} µALGOs. " + f"Cap for this transaction is {max_fee} µALGOs." + ) + elif transaction.fee > algosdk.constants.MIN_TXN_FEE: + logger.warning( + f"Algorand network congestion fees are in effect. " + f"This transaction will incur a fee of {transaction.fee} µALGOs." + ) + + +def transfer(client: AlgodClient, parameters: TransferParameters) -> PaymentTxn: + suggested_params = parameters.suggested_params or client.suggested_params() + from_account = parameters.from_account + sender = address_from_private_key(from_account.private_key) # type: ignore[no-untyped-call] transaction = PaymentTxn( - sender=transfer_parameters.from_account.address, + sender=sender, + receiver=parameters.to_address, + amt=parameters.micro_algos, + note=parameters.note.encode("utf-8") if isinstance(parameters.note, str) else parameters.note, sp=suggested_params, - receiver=transfer_parameters.to_address, - amt=transfer_parameters.amount, - close_remainder_to=None, - note=transfer_parameters.note.encode("utf-8") if transfer_parameters.note else None, - rekey_to=None, ) # type: ignore[no-untyped-call] - # TODO: max fee - from_account = transfer_parameters.from_account + if parameters.fee_micro_algos: + transaction.fee = parameters.fee_micro_algos + + if not suggested_params.flat_fee: + _check_fee(transaction, parameters.max_fee_micro_algos) signed_transaction = transaction.sign(from_account.private_key) # type: ignore[no-untyped-call] - send_response = client.send_transaction(signed_transaction) + client.send_transaction(signed_transaction) txid = transaction.get_txid() # type: ignore[no-untyped-call] - logger.debug(f"Sent transaction {txid} type={transaction.type} from {from_account.address}") + logger.debug( + f"Sent transaction {txid} type={transaction.type} from " + f"{address_from_private_key(from_account.private_key)}" # type: ignore[no-untyped-call] + ) - return transaction, send_response + return transaction diff --git a/src/algokit_utils/account.py b/src/algokit_utils/account.py index ada62b6..5a03b09 100644 --- a/src/algokit_utils/account.py +++ b/src/algokit_utils/account.py @@ -1,7 +1,7 @@ import logging import os from collections.abc import Callable -from typing import Any, cast +from typing import Any from algosdk.account import address_from_private_key from algosdk.kmd import KMDClient @@ -31,40 +31,49 @@ def get_account_from_mnemonic(mnemonic: str) -> Account: return Account(private_key, address) +def create_kmd_wallet_account(kmd_client: KMDClient, name: str) -> Account: + wallet_id = kmd_client.create_wallet(name, "")["id"] # type: ignore[no-untyped-call] + wallet_handle = kmd_client.init_wallet_handle(wallet_id, "") # type: ignore[no-untyped-call] + kmd_client.generate_key(wallet_handle) # type: ignore[no-untyped-call] + + key_ids: list[str] = kmd_client.list_keys(wallet_handle) # type: ignore[no-untyped-call] + account_key = key_ids[0] + + private_account_key = kmd_client.export_key(wallet_handle, "", account_key) # type: ignore[no-untyped-call] + return get_account_from_mnemonic(from_private_key(private_account_key)) # type: ignore[no-untyped-call] + + def get_or_create_kmd_wallet_account( - client: AlgodClient, name: str, fund_with: int | None, kmd_client: KMDClient | None = None + client: AlgodClient, name: str, fund_with_algos: float = 1000, kmd_client: KMDClient | None = None ) -> Account: kmd_client = kmd_client or get_kmd_client_from_algod_client(client) - fund_with = 1000 if fund_with is None else fund_with account = get_kmd_wallet_account(client, kmd_client, name) if account: - account_info = cast(dict[str, Any], client.account_info(account.address)) + account_info = client.account_info(account.address) + assert isinstance(account_info, dict) if account_info["amount"] > 0: return account - logger.debug(f"Found existing account in Sandbox with name '{name}'." f"But no funds in the account.") + logger.debug(f"Found existing account in Sandbox with name '{name}', but no funds in the account.") else: - wallet_id = kmd_client.create_wallet(name, "")["id"] # type: ignore[no-untyped-call] - wallet_handle = kmd_client.init_wallet_handle(wallet_id, "") # type: ignore[no-untyped-call] - kmd_client.generate_key(wallet_handle) # type: ignore[no-untyped-call] + account = create_kmd_wallet_account(kmd_client, name) - account = get_kmd_wallet_account(client, kmd_client, name) - assert account logger.debug( f"Couldn't find existing account in Sandbox with name '{name}'. " f"So created account {account.address} with keys stored in KMD." ) - logger.debug(f"Funding account {account.address} with {fund_with} ALGOs") + logger.debug(f"Funding account {account.address} with {fund_with_algos} ALGOs") - transfer( - TransferParameters( - from_account=get_dispenser_account(client), - to_address=account.address, - amount=algos_to_microalgos(fund_with), # type: ignore[no-untyped-call] - ), - client, - ) + if fund_with_algos: + transfer( + client, + TransferParameters( + from_account=get_dispenser_account(client), + to_address=account.address, + micro_algos=algos_to_microalgos(fund_with_algos), # type: ignore[no-untyped-call] + ), + ) return account @@ -105,7 +114,8 @@ def get_kmd_wallet_account( matched_account_key = None if predicate: for key in key_ids: - account = cast(dict[str, Any], client.account_info(key)) + account = client.account_info(key) + assert isinstance(account, dict) if predicate(account): matched_account_key = key else: @@ -119,7 +129,7 @@ def get_kmd_wallet_account( def get_account( - client: AlgodClient, name: str, fund_with: int | None = None, kmd_client: KMDClient | None = None + client: AlgodClient, name: str, fund_with_algos: float = 1000, kmd_client: KMDClient | None = None ) -> Account: mnemonic_key = f"{name.upper()}_MNEMONIC" mnemonic = os.getenv(mnemonic_key) @@ -127,7 +137,7 @@ def get_account( return get_account_from_mnemonic(mnemonic) if is_sandbox(client): - account = get_or_create_kmd_wallet_account(client, name, fund_with, kmd_client) + account = get_or_create_kmd_wallet_account(client, name, fund_with_algos, kmd_client) os.environ[mnemonic_key] = from_private_key(account.private_key) # type: ignore[no-untyped-call] return account diff --git a/src/algokit_utils/application_client.py b/src/algokit_utils/application_client.py index 5edb896..8efc6db 100644 --- a/src/algokit_utils/application_client.py +++ b/src/algokit_utils/application_client.py @@ -3,9 +3,8 @@ import logging import re from collections.abc import Sequence -from enum import Enum from math import ceil -from typing import Any, Literal, cast, overload +from typing import Any, Literal, TypedDict, cast, overload import algosdk from algosdk import transaction @@ -28,48 +27,29 @@ from algosdk.v2client.algod import AlgodClient from algosdk.v2client.indexer import IndexerClient -from algokit_utils.app import ( - DELETABLE_TEMPLATE_NAME, - UPDATABLE_TEMPLATE_NAME, - AppDeployMetaData, - AppLookup, - AppMetaData, - AppReference, - DeploymentFailedError, - TemplateValueDict, - _add_deploy_template_variables, - _check_template_variables, - _schema_is_less, - _schema_str, - _state_schema, - _strip_comments, - get_creator_apps, - replace_template_variables, -) -from algokit_utils.application_specification import ( - ApplicationSpecification, - CallConfig, - DefaultArgumentDict, - MethodConfigDict, - MethodHints, - OnCompleteActionName, -) +import algokit_utils.application_specification as au_spec +import algokit_utils.deploy as au_deploy +from algokit_utils.deploy import check_app_and_deploy from algokit_utils.logic_error import LogicError, parse_logic_error -from algokit_utils.models import Account +from algokit_utils.models import ABITransactionResponse, Account, TransactionResponse logger = logging.getLogger(__name__) -ABIArgsDict = dict[str, Any] +ABIArgType = Any +ABIArgsDict = dict[str, ABIArgType] __all__ = [ "ABICallArgs", + "ABICallArgsDict", "ApplicationClient", - "DeployResponse", - "OnUpdate", - "OnSchemaBreak", - "OperationPerformed", + "CommonCallParameters", + "CommonCallParametersDict", + "OnCompleteCallParameters", + "OnCompleteCallParametersDict", + "CreateCallParameters", + "CreateCallParametersDict", "Program", - "TransactionResponse", + "execute_atc_with_logic_error", "get_app_id_from_tx_id", "get_next_version", "num_extra_program_pages", @@ -96,47 +76,79 @@ def num_extra_program_pages(approval: bytes, clear: bytes) -> int: @dataclasses.dataclass(kw_only=True) -class ABICallArgs: - method: Method | str | bool | None - args: ABIArgsDict = dataclasses.field(default_factory=dict) - bare: bool = False - lease: str | bytes | None = dataclasses.field(default=None) +class CommonCallParameters: + signer: TransactionSigner | None = None + sender: str | None = None + suggested_params: transaction.SuggestedParams | None = None + note: bytes | str | None = None + lease: bytes | str | None = None + accounts: list[str] | None = None + foreign_apps: list[int] | None = None + foreign_assets: list[int] | None = None + boxes: Sequence[tuple[int, bytes | bytearray | str | int]] | None = None + rekey_to: str | None = None + + +@dataclasses.dataclass(kw_only=True) +class OnCompleteCallParameters(CommonCallParameters): + on_complete: transaction.OnComplete | None = None -class OnUpdate(Enum): - Fail = 0 - UpdateApp = 1 - ReplaceApp = 2 - # TODO: AppendApp +@dataclasses.dataclass(kw_only=True) +class CreateCallParameters(OnCompleteCallParameters): + extra_pages: int | None = None -class OnSchemaBreak(Enum): - Fail = 0 - ReplaceApp = 2 +class CommonCallParametersDict(TypedDict, total=False): + signer: TransactionSigner + sender: str + suggested_params: transaction.SuggestedParams + note: bytes | str + lease: bytes | str -class OperationPerformed(Enum): - Nothing = 0 - Create = 1 - Update = 2 - Replace = 3 +class OnCompleteCallParametersDict(TypedDict, CommonCallParametersDict, total=False): + on_complete: transaction.OnComplete -@dataclasses.dataclass -class DeployResponse: - app: AppMetaData - action_taken: OperationPerformed = OperationPerformed.Nothing +class CreateCallParametersDict(TypedDict, OnCompleteCallParametersDict, total=False): + extra_pages: int @dataclasses.dataclass(kw_only=True) -class TransactionResponse: - tx_id: str - confirmed_round: int | None +class ABICallArgs: + method: Method | str | bool | None = None + args: ABIArgsDict = dataclasses.field(default_factory=dict) + suggested_params: transaction.SuggestedParams | None = None + lease: bytes | str | None = None + accounts: list[str] | None = None + foreign_apps: list[int] | None = None + foreign_assets: list[int] | None = None + boxes: Sequence[tuple[int, bytes | bytearray | str | int]] | None = None + rekey_to: str | None = None @dataclasses.dataclass(kw_only=True) -class ABITransactionResponse(TransactionResponse): - abi_result: ABIResult +class ABICreateCallArgs(ABICallArgs): + extra_pages: int | None = None + on_complete: transaction.OnComplete | None = None + + +class ABICallArgsDict(TypedDict, total=False): + method: Method | str | bool + args: ABIArgsDict + suggested_params: transaction.SuggestedParams + lease: bytes | str + accounts: list[str] + foreign_apps: list[int] + foreign_assets: list[int] + boxes: Sequence[tuple[int, bytes | bytearray | str | int]] + rekey_to: str + + +class ABICreateCallArgsDict(TypedDict, ABICallArgsDict, total=False): + extra_pages: int | None + on_complete: transaction.OnComplete class ApplicationClient: @@ -144,12 +156,13 @@ class ApplicationClient: def __init__( self, algod_client: AlgodClient, - app_spec: ApplicationSpecification, + app_spec: au_spec.ApplicationSpecification, *, app_id: int = 0, signer: TransactionSigner | Account | None = None, sender: str | None = None, suggested_params: transaction.SuggestedParams | None = None, + template_values: au_deploy.TemplateValueDict | None = None, ): ... @@ -157,34 +170,48 @@ def __init__( def __init__( self, algod_client: AlgodClient, - app_spec: ApplicationSpecification, + app_spec: au_spec.ApplicationSpecification, *, creator: str | Account, indexer_client: IndexerClient | None = None, - existing_deployments: AppLookup | None = None, + existing_deployments: au_deploy.AppLookup | None = None, signer: TransactionSigner | Account | None = None, sender: str | None = None, suggested_params: transaction.SuggestedParams | None = None, + template_values: au_deploy.TemplateValueDict | None = None, ): ... def __init__( self, algod_client: AlgodClient, - app_spec: ApplicationSpecification, + app_spec: au_spec.ApplicationSpecification, *, app_id: int = 0, creator: str | Account | None = None, indexer_client: IndexerClient | None = None, - existing_deployments: AppLookup | None = None, + existing_deployments: au_deploy.AppLookup | None = None, signer: TransactionSigner | Account | None = None, sender: str | None = None, suggested_params: transaction.SuggestedParams | None = None, + template_values: au_deploy.TemplateValueDict | None = None, ): self.algod_client = algod_client self.app_spec = app_spec - self._approval_program: Program | None = None - self._clear_program: Program | None = None + self._approval_program: Program | None + self._clear_program: Program | None + + if template_values: + self._approval_program, self._clear_program = substitute_template_and_compile( + self.algod_client, app_spec, template_values + ) + elif not au_deploy.has_template_vars(app_spec): + self._approval_program = Program(self.app_spec.approval_program, self.algod_client) + self._clear_program = Program(self.app_spec.clear_program, self.algod_client) + else: # can't compile programs yet + self._approval_program = None + self._clear_program = None + self.approval_source_map: SourceMap | None = None self.existing_deployments = existing_deployments self._indexer_client = indexer_client @@ -234,6 +261,37 @@ def approval(self) -> Program | None: def clear(self) -> Program | None: return self._clear_program + def prepare( + self, + signer: TransactionSigner | Account | None = None, + sender: str | None = None, + app_id: int | None = None, + template_values: au_deploy.TemplateValueDict | None = None, + ) -> "ApplicationClient": + import copy + + new_client = copy.copy(self) + new_client._prepare(new_client, signer=signer, sender=sender, app_id=app_id, template_values=template_values) + return new_client + + def _prepare( + self, + target: "ApplicationClient", + signer: TransactionSigner | Account | None = None, + sender: str | None = None, + app_id: int | None = None, + template_values: au_deploy.TemplateValueDict | None = None, + ) -> None: + target.app_id = self.app_id if app_id is None else app_id + if signer or sender: + target.signer, target.sender = target._resolve_signer_sender( + AccountTransactionSigner(signer.private_key) if isinstance(signer, Account) else signer, sender + ) + if template_values: + target._approval_program, target._clear_program = substitute_template_and_compile( + target.algod_client, target.app_spec, template_values + ) + def deploy( self, version: str | None = None, @@ -242,40 +300,84 @@ def deploy( sender: str | None = None, allow_update: bool | None = None, allow_delete: bool | None = None, - on_update: OnUpdate = OnUpdate.Fail, - on_schema_break: OnSchemaBreak = OnSchemaBreak.Fail, - template_values: TemplateValueDict | None = None, - create_args: ABICallArgs | None = None, - update_args: ABICallArgs | None = None, - delete_args: ABICallArgs | None = None, - ) -> DeployResponse: + on_update: au_deploy.OnUpdate = au_deploy.OnUpdate.Fail, + on_schema_break: au_deploy.OnSchemaBreak = au_deploy.OnSchemaBreak.Fail, + template_values: au_deploy.TemplateValueDict | None = None, + create_args: ABICreateCallArgs | ABICreateCallArgsDict | None = None, + update_args: ABICallArgs | ABICallArgsDict | None = None, + delete_args: ABICallArgs | ABICallArgsDict | None = None, + ) -> au_deploy.DeployResponse: + before = self._approval_program, self._clear_program, self.sender, self.signer, self.app_id + try: + return self._deploy( + version, + signer=signer, + sender=sender, + allow_update=allow_update, + allow_delete=allow_delete, + on_update=on_update, + on_schema_break=on_schema_break, + template_values=template_values, + create_args=create_args, + update_args=update_args, + delete_args=delete_args, + ) + except Exception as ex: + # undo any prepare changes if there was an error + self._approval_program, self._clear_program, self.sender, self.signer, self.app_id = before + raise ex from None + + def _deploy( + self, + version: str | None, + *, + signer: TransactionSigner | None, + sender: str | None, + allow_update: bool | None, + allow_delete: bool | None, + on_update: au_deploy.OnUpdate, + on_schema_break: au_deploy.OnSchemaBreak, + template_values: au_deploy.TemplateValueDict | None, + create_args: ABICallArgs | ABICallArgsDict | None, + update_args: ABICallArgs | ABICallArgsDict | None, + delete_args: ABICallArgs | ABICallArgsDict | None, + ) -> au_deploy.DeployResponse: """Ensures app associated with app client's creator is present and up to date""" if self.app_id: - raise DeploymentFailedError(f"Attempt to deploy app which already has an app index of {self.app_id}") + raise au_deploy.DeploymentFailedError( + f"Attempt to deploy app which already has an app index of {self.app_id}" + ) signer, sender = self._resolve_signer_sender(signer, sender) if not sender: - raise DeploymentFailedError("No sender provided, unable to deploy app") + raise au_deploy.DeploymentFailedError("No sender provided, unable to deploy app") if not self._creator: - raise DeploymentFailedError("No creator provided, unable to deploy app") + raise au_deploy.DeploymentFailedError("No creator provided, unable to deploy app") if self._creator != sender: - raise DeploymentFailedError( + raise au_deploy.DeploymentFailedError( f"Attempt to deploy contract with a sender address {sender} that differs " f"from the given creator address for this application client: {self._creator}" ) + # make a copy - template_values = template_values or {} - _add_deploy_template_variables(template_values, allow_update=allow_update, allow_delete=allow_delete) - approval_program, clear_program = self._substitute_template_and_compile(template_values) + template_values = dict(template_values or {}) + au_deploy.add_deploy_template_variables(template_values, allow_update=allow_update, allow_delete=allow_delete) + + self._prepare(self, template_values=template_values) + approval_program, clear_program = self._check_is_compiled() updatable = ( allow_update if allow_update is not None - else _get_deploy_control(self.app_spec, UPDATABLE_TEMPLATE_NAME, transaction.OnComplete.UpdateApplicationOC) + else au_deploy.get_deploy_control( + self.app_spec, au_deploy.UPDATABLE_TEMPLATE_NAME, transaction.OnComplete.UpdateApplicationOC + ) ) deletable = ( allow_delete if allow_delete is not None - else _get_deploy_control(self.app_spec, DELETABLE_TEMPLATE_NAME, transaction.OnComplete.DeleteApplicationOC) + else au_deploy.get_deploy_control( + self.app_spec, au_deploy.DELETABLE_TEMPLATE_NAME, transaction.OnComplete.DeleteApplicationOC + ) ) name = self.app_spec.contract.name @@ -287,275 +389,188 @@ def deploy( if app.app_id == 0: version = "v1.0" else: - assert isinstance(app, AppDeployMetaData) + assert isinstance(app, au_deploy.AppDeployMetaData) version = get_next_version(app.version) - app_spec_note = AppDeployMetaData(name, version, updatable=updatable, deletable=deletable) - - def unpack_args( - args: ABICallArgs | None, - ) -> tuple[Method | str | bool | None, ABIArgsDict | None, bytes | None]: - if args is None: - return None, None, None - return args.method, args.args, args.lease.encode("utf-8") if isinstance(args.lease, str) else args.lease - - def create_metadata( - created_round: int, updated_round: int | None = None, original_metadata: AppDeployMetaData | None = None - ) -> AppMetaData: - app_metadata = AppMetaData( - app_id=self.app_id, - app_address=self.app_address, - created_metadata=original_metadata or app_spec_note, - created_round=created_round, - updated_round=updated_round or created_round, - **app_spec_note.__dict__, - deleted=False, - ) - return app_metadata + app_spec_note = au_deploy.AppDeployMetaData(name, version, updatable=updatable, deletable=deletable) - def create_app() -> DeployResponse: + def create_app() -> au_deploy.DeployResponse: assert self.existing_deployments - method, args, lease = unpack_args(create_args) - create_result = self.create( - abi_method=method, - args=args, - note=app_spec_note.encode(), - signer=signer, - sender=sender, - template_values=template_values, - lease=lease, + + method, abi_args, parameters = _convert_deploy_args(create_args, app_spec_note, signer, sender) + create_response = self.create( + method, + parameters, + **abi_args, ) logger.info(f"{name} ({version}) deployed successfully, with app id {self.app_id}.") - assert create_result.confirmed_round is not None - app_metadata = create_metadata(create_result.confirmed_round) + assert create_response.confirmed_round is not None + app_metadata = _create_metadata(app_spec_note, self.app_id, create_response.confirmed_round) self.existing_deployments.apps[name] = app_metadata - return DeployResponse(app_metadata, action_taken=OperationPerformed.Create) + return au_deploy.DeployResponse( + app=app_metadata, create_response=create_response, action_taken=au_deploy.OperationPerformed.Create + ) if app.app_id == 0: logger.info(f"{name} not found in {self._creator} account, deploying app.") return create_app() - assert isinstance(app, AppMetaData) - logger.debug(f"{name} found in {self._creator} account, with app id {app.app_id}, version={app.version}.") - - application_info = cast(dict[str, Any], self.algod_client.application_info(app.app_id)) - application_create_params = application_info["params"] - - current_approval = base64.b64decode(application_create_params["approval-program"]) - current_clear = base64.b64decode(application_create_params["clear-state-program"]) - current_global_schema = _state_schema(application_create_params["global-state-schema"]) - current_local_schema = _state_schema(application_create_params["local-state-schema"]) - - required_global_schema = self.app_spec.global_state_schema - required_local_schema = self.app_spec.local_state_schema - new_approval = approval_program.raw_binary - new_clear = clear_program.raw_binary - - app_updated = current_approval != new_approval or current_clear != new_clear - - schema_breaking_change = _schema_is_less(current_global_schema, required_global_schema) or _schema_is_less( - current_local_schema, required_local_schema - ) - - def create_and_delete_app() -> DeployResponse: - assert isinstance(app, AppMetaData) + def create_and_delete_app() -> au_deploy.DeployResponse: + assert isinstance(app, au_deploy.AppMetaData) assert self.existing_deployments + logger.info(f"Replacing {name} ({app.version}) with {name} ({version}) in {self._creator} account.") - create_method, c_args, create_lease = unpack_args(create_args) - delete_method, d_args, delete_lease = unpack_args(delete_args) atc = AtomicTransactionComposer() + create_method, create_abi_args, create_parameters = _convert_deploy_args( + create_args, app_spec_note, signer, sender + ) self.compose_create( atc, - abi_method=create_method, - args=c_args, - note=app_spec_note.encode(), - signer=signer, - sender=sender, - template_values=template_values, - lease=create_lease, + create_method, + create_parameters, + **create_abi_args, + ) + delete_method, delete_abi_args, delete_parameters = _convert_deploy_args( + delete_args, app_spec_note, signer, sender ) self.compose_delete( atc, - abi_method=delete_method, - args=d_args, - signer=signer, - sender=sender, - lease=delete_lease, + delete_method, + delete_parameters, + **delete_abi_args, ) - create_delete_result = self._execute_atc(atc) - self._set_app_id_from_tx_id(create_delete_result.tx_ids[0]) + create_delete_response = self.execute_atc(atc) + create_response = _tr_from_atr(atc, create_delete_response, 0) + delete_response = _tr_from_atr(atc, create_delete_response, 1) + self._set_app_id_from_tx_id(create_response.tx_id) logger.info(f"{name} ({version}) deployed successfully, with app id {self.app_id}.") logger.info(f"{name} ({app.version}) with app id {app.app_id}, deleted successfully.") - app_metadata = create_metadata(create_delete_result.confirmed_round) + app_metadata = _create_metadata(app_spec_note, self.app_id, create_delete_response.confirmed_round) self.existing_deployments.apps[name] = app_metadata - # TODO: include transaction responses - return DeployResponse(app_metadata, action_taken=OperationPerformed.Replace) - def update_app() -> DeployResponse: - assert on_update == OnUpdate.UpdateApp - assert isinstance(app, AppMetaData) + return au_deploy.DeployResponse( + app=app_metadata, + create_response=create_response, + delete_response=delete_response, + action_taken=au_deploy.OperationPerformed.Replace, + ) + + def update_app() -> au_deploy.DeployResponse: + assert on_update == au_deploy.OnUpdate.UpdateApp + assert isinstance(app, au_deploy.AppMetaData) assert self.existing_deployments logger.info(f"Updating {name} to {version} in {self._creator} account, with app id {app.app_id}") - method, args, lease = unpack_args(update_args) - update_result = self.update( - abi_method=method, - args=args, - note=app_spec_note.encode(), - signer=signer, - sender=sender, - template_values=template_values, - lease=lease, + method, abi_args, parameters = _convert_deploy_args(update_args, app_spec_note, signer, sender) + update_response = self.update( + method, + parameters, + **abi_args, ) - app_metadata = create_metadata( - app.created_round, updated_round=update_result.confirmed_round, original_metadata=app.created_metadata + app_metadata = _create_metadata( + app_spec_note, + self.app_id, + app.created_round, + updated_round=update_response.confirmed_round, + original_metadata=app.created_metadata, ) self.existing_deployments.apps[name] = app_metadata - return DeployResponse(app_metadata, action_taken=OperationPerformed.Update) - - if schema_breaking_change: - logger.warning( - f"Detected a breaking app schema change from: " - f"{_schema_str(current_global_schema, current_local_schema)} to " - f"{_schema_str(required_global_schema, required_local_schema)}." + return au_deploy.DeployResponse( + app=app_metadata, update_response=update_response, action_taken=au_deploy.OperationPerformed.Update ) - if on_schema_break == OnSchemaBreak.Fail: - raise DeploymentFailedError( - "Schema break detected and on_schema_break=OnSchemaBreak.Fail, stopping deployment. " - "If you want to try deleting and recreating the app then " - "re-run with on_schema_break=OnSchemaBreak.ReplaceApp" - ) - if app.deletable: - logger.info( - "App is deletable and on_schema_break=ReplaceApp, will attempt to create new app and delete old app" - ) - elif app.deletable is False: - logger.warning( - "App is not deletable but on_schema_break=ReplaceApp, " - "will attempt to delete app, delete will most likely fail" - ) - else: - logger.warning( - "Cannot determine if App is deletable but on_schema_break=ReplaceApp, " "will attempt to delete app" - ) - return create_and_delete_app() - elif app_updated: - logger.info(f"Detected a TEAL update in app id {app.app_id}") - - if on_update == OnUpdate.Fail: - raise DeploymentFailedError( - "Update detected and on_update=Fail, stopping deployment. " - "If you want to try updating the app then re-run with on_update=UpdateApp" - ) - if app.updatable and on_update == OnUpdate.UpdateApp: - logger.info("App is updatable and on_update=UpdateApp, will update app") - return update_app() - elif app.updatable and on_update == OnUpdate.ReplaceApp: - logger.warning( - "App is updatable but on_update=ReplaceApp, will attempt to create new app and delete old app" - ) - return create_and_delete_app() - elif on_update == OnUpdate.ReplaceApp: - if app.updatable is False: - logger.warning( - "App is not updatable and on_update=ReplaceApp, " - "will attempt to create new app and delete old app" - ) - else: - logger.warning( - "Cannot determine if App is updatable and on_update=ReplaceApp, " - "will attempt to create new app and delete old app" - ) - return create_and_delete_app() - else: - if app.updatable is False: - logger.warning( - "App is not updatable but on_update=UpdateApp, " - "will attempt to update app, update will most likely fail" - ) - else: - logger.warning( - "Cannot determine if App is updatable and on_update=UpdateApp, " "will attempt to update app" - ) - return update_app() + assert isinstance(app, au_deploy.AppMetaData) + logger.debug(f"{name} found in {self._creator} account, with app id {app.app_id}, version={app.version}.") - logger.info("No detected changes in app, nothing to do.") + app_changes = au_deploy.check_for_app_changes( + self.algod_client, + new_approval=approval_program.raw_binary, + new_clear=clear_program.raw_binary, + new_global_schema=self.app_spec.global_state_schema, + new_local_schema=self.app_spec.local_state_schema, + app_id=app.app_id, + ) - return DeployResponse(app) + return check_app_and_deploy( + app, + app_changes, + on_update=on_update, + on_schema_break=on_schema_break, + update_app=update_app, + create_and_delete_app=create_and_delete_app, + ) def compose_create( self, atc: AtomicTransactionComposer, - abi_method: Method | str | bool | None = None, - args: ABIArgsDict | None = None, - *, - signer: TransactionSigner | None = None, - sender: str | None = None, - suggested_params: transaction.SuggestedParams | None = None, - on_complete: transaction.OnComplete = transaction.OnComplete.NoOpOC, - template_values: TemplateValueDict | None = None, - extra_pages: int | None = None, - note: bytes | str | None = None, - lease: bytes | None = None, - ) -> tuple[Program, Program]: + /, + call_abi_method: Method | str | bool | None = None, + transaction_parameters: CreateCallParameters | CreateCallParametersDict | None = None, + **abi_kwargs: ABIArgType, + ) -> None: """Adds a signed transaction with application id == 0 and the schema and source of client's app_spec to atc""" + approval_program, clear_program = self._check_is_compiled() + transaction_parameters = _convert_call_parameters(transaction_parameters) - approval_program, clear_program = self._substitute_template_and_compile(template_values) - - if extra_pages is None: - extra_pages = num_extra_program_pages(approval_program.raw_binary, clear_program.raw_binary) + extra_pages = transaction_parameters.extra_pages or num_extra_program_pages( + approval_program.raw_binary, clear_program.raw_binary + ) - create_method = self._resolve_method(abi_method, args, on_complete, CallConfig.CREATE) - self._add_method_call( + self.add_method_call( atc, app_id=0, - method=create_method, - abi_args=args, - signer=signer, - sender=sender, - suggested_params=suggested_params, - on_complete=on_complete, + abi_method=call_abi_method, + abi_args=abi_kwargs, + on_complete=transaction_parameters.on_complete or transaction.OnComplete.NoOpOC, + call_config=au_spec.CallConfig.CREATE, + parameters=transaction_parameters, approval_program=approval_program.raw_binary, clear_program=clear_program.raw_binary, global_schema=self.app_spec.global_state_schema, local_schema=self.app_spec.local_state_schema, extra_pages=extra_pages, - note=note, - lease=lease, ) - return approval_program, clear_program + @overload + def create( + self, + call_abi_method: Literal[False], + transaction_parameters: CreateCallParameters | CreateCallParametersDict | None = ..., + ) -> TransactionResponse: + ... + @overload def create( self, - abi_method: Method | str | bool | None = None, - args: ABIArgsDict | None = None, - *, - signer: TransactionSigner | None = None, - sender: str | None = None, - suggested_params: transaction.SuggestedParams | None = None, - on_complete: transaction.OnComplete = transaction.OnComplete.NoOpOC, - template_values: TemplateValueDict | None = None, - extra_pages: int | None = None, - note: bytes | str | None = None, - lease: bytes | None = None, + call_abi_method: Method | str | Literal[True], + transaction_parameters: CreateCallParameters | CreateCallParametersDict | None = ..., + **abi_kwargs: ABIArgType, + ) -> ABITransactionResponse: + ... + + @overload + def create( + self, + call_abi_method: Method | str | bool | None = ..., + transaction_parameters: CreateCallParameters | CreateCallParametersDict | None = ..., + **abi_kwargs: ABIArgType, + ) -> TransactionResponse | ABITransactionResponse: + ... + + def create( + self, + call_abi_method: Method | str | bool | None = None, + transaction_parameters: CreateCallParameters | CreateCallParametersDict | None = None, + **abi_kwargs: ABIArgType, ) -> TransactionResponse | ABITransactionResponse: """Submits a signed transaction with application id == 0 and the schema and source of client's app_spec""" atc = AtomicTransactionComposer() - self._approval_program, self._clear_program = self.compose_create( + self.compose_create( atc, - abi_method, - args, - signer=signer, - sender=sender, - suggested_params=suggested_params, - on_complete=on_complete, - template_values=template_values, - extra_pages=extra_pages, - note=note, - lease=lease, + call_abi_method, + transaction_parameters, + **abi_kwargs, ) create_result = self._execute_atc_tr(atc) self._set_app_id_from_tx_id(create_result.tx_id) @@ -564,393 +579,350 @@ def create( def compose_update( self, atc: AtomicTransactionComposer, - abi_method: Method | str | bool | None = None, - args: ABIArgsDict | None = None, - *, - signer: TransactionSigner | None = None, - sender: str | None = None, - suggested_params: transaction.SuggestedParams | None = None, - template_values: TemplateValueDict | None = None, - note: bytes | str | None = None, - lease: bytes | None = None, - ) -> tuple[Program, Program]: + /, + call_abi_method: Method | str | bool | None = None, + transaction_parameters: CommonCallParameters | CommonCallParametersDict | None = None, + **abi_kwargs: ABIArgType, + ) -> None: """Adds a signed transaction with on_complete=UpdateApplication to atc""" + approval_program, clear_program = self._check_is_compiled() - self._load_reference_and_check_app_id() - approval_program, clear_program = self._substitute_template_and_compile(template_values) - - update_method = self._resolve_method(abi_method, args, transaction.OnComplete.UpdateApplicationOC) - self._add_method_call( + self.add_method_call( atc=atc, - method=update_method, - abi_args=args, - signer=signer, - sender=sender, - suggested_params=suggested_params, + abi_method=call_abi_method, + abi_args=abi_kwargs, + parameters=transaction_parameters, on_complete=transaction.OnComplete.UpdateApplicationOC, approval_program=approval_program.raw_binary, clear_program=clear_program.raw_binary, - note=note, - lease=lease, ) - return approval_program, clear_program + @overload + def update( + self, + call_abi_method: Method | str | Literal[True], + transaction_parameters: CommonCallParameters | CommonCallParametersDict | None = ..., + **abi_kwargs: ABIArgType, + ) -> ABITransactionResponse: + ... + @overload def update( self, - abi_method: Method | str | bool | None = None, - args: ABIArgsDict | None = None, - *, - signer: TransactionSigner | None = None, - sender: str | None = None, - suggested_params: transaction.SuggestedParams | None = None, - template_values: TemplateValueDict | None = None, - note: bytes | str | None = None, - lease: bytes | None = None, + call_abi_method: Literal[False], + transaction_parameters: CommonCallParameters | CommonCallParametersDict | None = ..., + ) -> TransactionResponse: + ... + + @overload + def update( + self, + call_abi_method: Method | str | bool | None = ..., + transaction_parameters: CommonCallParameters | CommonCallParametersDict | None = ..., + **abi_kwargs: ABIArgType, + ) -> TransactionResponse | ABITransactionResponse: + ... + + def update( + self, + call_abi_method: Method | str | bool | None = None, + transaction_parameters: CommonCallParameters | CommonCallParametersDict | None = None, + **abi_kwargs: ABIArgType, ) -> TransactionResponse | ABITransactionResponse: """Submits a signed transaction with on_complete=UpdateApplication""" atc = AtomicTransactionComposer() - self._approval_program, self._clear_program = self.compose_update( + self.compose_update( atc, - abi_method, - args, - signer=signer, - sender=sender, - suggested_params=suggested_params, - template_values=template_values, - note=note, - lease=lease, + call_abi_method, + transaction_parameters=transaction_parameters, + **abi_kwargs, ) return self._execute_atc_tr(atc) def compose_delete( self, atc: AtomicTransactionComposer, - abi_method: Method | str | bool | None = None, - args: ABIArgsDict | None = None, - *, - signer: TransactionSigner | None = None, - sender: str | None = None, - suggested_params: transaction.SuggestedParams | None = None, - lease: bytes | None = None, + /, + call_abi_method: Method | str | bool | None = None, + transaction_parameters: CommonCallParameters | CommonCallParametersDict | None = None, + **abi_kwargs: ABIArgType, ) -> None: """Adds a signed transaction with on_complete=DeleteApplication to atc""" - delete_method = self._resolve_method(abi_method, args, on_complete=transaction.OnComplete.DeleteApplicationOC) - self.compose_call( + self.add_method_call( atc, - delete_method, - args, - signer=signer, - sender=sender, - suggested_params=suggested_params, + call_abi_method, + abi_args=abi_kwargs, + parameters=transaction_parameters, on_complete=transaction.OnComplete.DeleteApplicationOC, - lease=lease, ) + @overload def delete( self, - abi_method: Method | str | bool | None = None, - args: ABIArgsDict | None = None, - *, - signer: TransactionSigner | None = None, - sender: str | None = None, - suggested_params: transaction.SuggestedParams | None = None, - lease: bytes | None = None, + call_abi_method: Method | str | Literal[True], + transaction_parameters: CommonCallParameters | CommonCallParametersDict | None = ..., + **abi_kwargs: ABIArgType, + ) -> ABITransactionResponse: + ... + + @overload + def delete( + self, + call_abi_method: Literal[False], + transaction_parameters: CommonCallParameters | CommonCallParametersDict | None = ..., + ) -> TransactionResponse: + ... + + @overload + def delete( + self, + call_abi_method: Method | str | bool | None = ..., + transaction_parameters: CommonCallParameters | CommonCallParametersDict | None = ..., + **abi_kwargs: ABIArgType, + ) -> TransactionResponse | ABITransactionResponse: + ... + + def delete( + self, + call_abi_method: Method | str | bool | None = None, + transaction_parameters: CommonCallParameters | CommonCallParametersDict | None = None, + **abi_kwargs: ABIArgType, ) -> TransactionResponse | ABITransactionResponse: """Submits a signed transaction with on_complete=DeleteApplication""" atc = AtomicTransactionComposer() self.compose_delete( atc, - abi_method, - args, - signer=signer, - sender=sender, - suggested_params=suggested_params, - lease=lease, + call_abi_method, + transaction_parameters=transaction_parameters, + **abi_kwargs, ) return self._execute_atc_tr(atc) def compose_call( self, atc: AtomicTransactionComposer, - abi_method: Method | str | bool | None = None, - args: ABIArgsDict | None = None, - *, - signer: TransactionSigner | None = None, - sender: str | None = None, - suggested_params: transaction.SuggestedParams | None = None, - on_complete: transaction.OnComplete = transaction.OnComplete.NoOpOC, - accounts: list[str] | None = None, - foreign_apps: list[int] | None = None, - foreign_assets: list[int] | None = None, - boxes: Sequence[tuple[int, bytes | bytearray | str | int]] | None = None, - note: bytes | str | None = None, - lease: bytes | None = None, - rekey_to: str | None = None, + /, + call_abi_method: Method | str | bool | None = None, + transaction_parameters: OnCompleteCallParameters | OnCompleteCallParametersDict | None = None, + **abi_kwargs: ABIArgType, ) -> None: """Adds a signed transaction with specified parameters to atc""" - - self._load_reference_and_check_app_id() - method = self._resolve_method(abi_method, args, on_complete) - self._add_method_call( + _parameters = _convert_call_parameters(transaction_parameters) + self.add_method_call( atc, - method=method, - abi_args=args, - signer=signer, - sender=sender, - suggested_params=suggested_params, - on_complete=on_complete, - accounts=accounts, - foreign_apps=foreign_apps, - foreign_assets=foreign_assets, - boxes=boxes, - note=note, - lease=lease, - rekey_to=rekey_to, + abi_method=call_abi_method, + abi_args=abi_kwargs, + parameters=_parameters, + on_complete=_parameters.on_complete or transaction.OnComplete.NoOpOC, ) @overload def call( self, - abi_method: Method | str | Literal[True], - args: ABIArgsDict | None = None, - *, - signer: TransactionSigner | None = None, - sender: str | None = None, - suggested_params: transaction.SuggestedParams | None = None, - on_complete: transaction.OnComplete = transaction.OnComplete.NoOpOC, - accounts: list[str] | None = None, - foreign_apps: list[int] | None = None, - foreign_assets: list[int] | None = None, - boxes: Sequence[tuple[int, bytes | bytearray | str | int]] | None = None, - note: bytes | str | None = None, - lease: bytes | None = None, - rekey_to: str | None = None, + call_abi_method: Method | str | Literal[True], + transaction_parameters: OnCompleteCallParameters | OnCompleteCallParametersDict | None = ..., + **abi_kwargs: ABIArgType, ) -> ABITransactionResponse: ... @overload def call( self, - abi_method: Literal[False], - args: ABIArgsDict | None = None, - *, - signer: TransactionSigner | None = None, - sender: str | None = None, - suggested_params: transaction.SuggestedParams | None = None, - on_complete: transaction.OnComplete = transaction.OnComplete.NoOpOC, - accounts: list[str] | None = None, - foreign_apps: list[int] | None = None, - foreign_assets: list[int] | None = None, - boxes: Sequence[tuple[int, bytes | bytearray | str | int]] | None = None, - note: bytes | str | None = None, - lease: bytes | None = None, - rekey_to: str | None = None, + call_abi_method: Literal[False], + transaction_parameters: OnCompleteCallParameters | OnCompleteCallParametersDict | None = ..., ) -> TransactionResponse: ... + @overload def call( self, - abi_method: Method | str | bool | None = None, - args: ABIArgsDict | None = None, - *, - signer: TransactionSigner | None = None, - sender: str | None = None, - suggested_params: transaction.SuggestedParams | None = None, - on_complete: transaction.OnComplete = transaction.OnComplete.NoOpOC, - accounts: list[str] | None = None, - foreign_apps: list[int] | None = None, - foreign_assets: list[int] | None = None, - boxes: Sequence[tuple[int, bytes | bytearray | str | int]] | None = None, - note: bytes | str | None = None, - lease: bytes | None = None, - rekey_to: str | None = None, + call_abi_method: Method | str | bool | None = ..., + transaction_parameters: OnCompleteCallParameters | OnCompleteCallParametersDict | None = ..., + **abi_kwargs: ABIArgType, ) -> TransactionResponse | ABITransactionResponse: - """Submits a signed transaction with specified parameters""" + ... + def call( + self, + call_abi_method: Method | str | bool | None = None, + transaction_parameters: OnCompleteCallParameters | OnCompleteCallParametersDict | None = None, + **abi_kwargs: ABIArgType, + ) -> TransactionResponse | ABITransactionResponse: + """Submits a signed transaction with specified parameters""" atc = AtomicTransactionComposer() - method = self._resolve_method(abi_method, args, on_complete) + _parameters = _convert_call_parameters(transaction_parameters) self.compose_call( atc, - abi_method=method, - args=args, - signer=signer, - sender=sender, - suggested_params=suggested_params, - on_complete=on_complete, - accounts=accounts, - foreign_apps=foreign_apps, - foreign_assets=foreign_assets, - boxes=boxes, - note=note, - lease=lease, - rekey_to=rekey_to, + call_abi_method=call_abi_method, + transaction_parameters=_parameters, + **abi_kwargs, ) + method = self._resolve_method( + call_abi_method, abi_kwargs, _parameters.on_complete or transaction.OnComplete.NoOpOC + ) # If its a read-only method, use dryrun (TODO: swap with simulate later?) if method: - hints = self._method_hints(method) - if hints and hints.read_only: - dr_req = transaction.create_dryrun(self.algod_client, atc.gather_signatures()) # type: ignore[arg-type] - dr_result = self.algod_client.dryrun(dr_req) # type: ignore[arg-type] - for txn in dr_result["txns"]: - if "app-call-messages" in txn and "REJECT" in txn["app-call-messages"]: - msg = ", ".join(txn["app-call-messages"]) - raise Exception(f"Dryrun for readonly method failed: {msg}") - - method_results = _parse_result({0: method}, dr_result["txns"], atc.tx_ids) - return ABITransactionResponse(abi_result=method_results[0], tx_id=atc.tx_ids[0], confirmed_round=None) + response = self._try_dry_run_call(method, atc) + if response: + return response return self._execute_atc_tr(atc) def compose_opt_in( self, atc: AtomicTransactionComposer, - abi_method: Method | str | bool | None = None, - args: ABIArgsDict | None = None, - *, - signer: TransactionSigner | None = None, - sender: str | None = None, - suggested_params: transaction.SuggestedParams | None = None, - note: bytes | str | None = None, - lease: bytes | None = None, + /, + call_abi_method: Method | str | bool | None = None, + transaction_parameters: CommonCallParameters | CommonCallParametersDict | None = None, + **abi_kwargs: ABIArgType, ) -> None: """Adds a signed transaction with on_complete=OptIn to atc""" - self.compose_call( + self.add_method_call( atc, - abi_method=abi_method, - args=args, - signer=signer, - sender=sender, - suggested_params=suggested_params, + abi_method=call_abi_method, + abi_args=abi_kwargs, + parameters=transaction_parameters, on_complete=transaction.OnComplete.OptInOC, - note=note, - lease=lease, ) + @overload def opt_in( self, - abi_method: Method | str | bool | None = None, - args: ABIArgsDict | None = None, - *, - signer: TransactionSigner | None = None, - sender: str | None = None, - suggested_params: transaction.SuggestedParams | None = None, - note: bytes | str | None = None, - lease: bytes | None = None, + call_abi_method: Method | str | Literal[True] = ..., + transaction_parameters: CommonCallParameters | CommonCallParametersDict | None = None, + **abi_kwargs: ABIArgType, + ) -> ABITransactionResponse: + ... + + @overload + def opt_in( + self, + call_abi_method: Literal[False] = ..., + transaction_parameters: CommonCallParameters | CommonCallParametersDict | None = None, + ) -> TransactionResponse: + ... + + @overload + def opt_in( + self, + call_abi_method: Method | str | bool | None = ..., + transaction_parameters: CommonCallParameters | CommonCallParametersDict | None = ..., + **abi_kwargs: ABIArgType, + ) -> TransactionResponse | ABITransactionResponse: + ... + + def opt_in( + self, + call_abi_method: Method | str | bool | None = None, + transaction_parameters: CommonCallParameters | CommonCallParametersDict | None = None, + **abi_kwargs: ABIArgType, ) -> TransactionResponse | ABITransactionResponse: """Submits a signed transaction with on_complete=OptIn""" atc = AtomicTransactionComposer() self.compose_opt_in( atc, - abi_method=abi_method, - args=args, - signer=signer, - sender=sender, - suggested_params=suggested_params, - note=note, - lease=lease, + call_abi_method=call_abi_method, + transaction_parameters=transaction_parameters, + **abi_kwargs, ) return self._execute_atc_tr(atc) def compose_close_out( self, atc: AtomicTransactionComposer, - abi_method: Method | str | bool | None = None, - args: ABIArgsDict | None = None, - *, - signer: TransactionSigner | None = None, - sender: str | None = None, - suggested_params: transaction.SuggestedParams | None = None, - note: bytes | str | None = None, - lease: bytes | None = None, + /, + call_abi_method: Method | str | bool | None = None, + transaction_parameters: CommonCallParameters | CommonCallParametersDict | None = None, + **abi_kwargs: ABIArgType, ) -> None: """Adds a signed transaction with on_complete=CloseOut to ac""" - return self.compose_call( + self.add_method_call( atc, - abi_method=abi_method, - args=args, - signer=signer, - sender=sender, + abi_method=call_abi_method, + abi_args=abi_kwargs, + parameters=transaction_parameters, on_complete=transaction.OnComplete.CloseOutOC, - suggested_params=suggested_params, - note=note, - lease=lease, ) + @overload def close_out( self, - abi_method: Method | str | bool | None = None, - args: ABIArgsDict | None = None, - *, - signer: TransactionSigner | None = None, - sender: str | None = None, - suggested_params: transaction.SuggestedParams | None = None, - note: bytes | str | None = None, - lease: bytes | None = None, + call_abi_method: Method | str | Literal[True], + transaction_parameters: CommonCallParameters | CommonCallParametersDict | None = ..., + **abi_kwargs: ABIArgType, + ) -> ABITransactionResponse: + ... + + @overload + def close_out( + self, + call_abi_method: Literal[False], + transaction_parameters: CommonCallParameters | CommonCallParametersDict | None = ..., + ) -> TransactionResponse: + ... + + @overload + def close_out( + self, + call_abi_method: Method | str | bool | None = ..., + transaction_parameters: CommonCallParameters | CommonCallParametersDict | None = ..., + **abi_kwargs: ABIArgType, + ) -> TransactionResponse | ABITransactionResponse: + ... + + def close_out( + self, + call_abi_method: Method | str | bool | None = None, + transaction_parameters: CommonCallParameters | CommonCallParametersDict | None = None, + **abi_kwargs: ABIArgType, ) -> TransactionResponse | ABITransactionResponse: """Submits a signed transaction with on_complete=CloseOut""" atc = AtomicTransactionComposer() self.compose_close_out( atc, - abi_method=abi_method, - args=args, - signer=signer, - sender=sender, - suggested_params=suggested_params, - note=note, - lease=lease, + call_abi_method=call_abi_method, + transaction_parameters=transaction_parameters, + **abi_kwargs, ) return self._execute_atc_tr(atc) def compose_clear_state( self, atc: AtomicTransactionComposer, - *, - signer: TransactionSigner | None = None, - sender: str | None = None, - suggested_params: transaction.SuggestedParams | None = None, - note: bytes | str | None = None, - lease: bytes | None = None, + /, + transaction_parameters: CommonCallParameters | CommonCallParametersDict | None = None, + app_args: list[bytes] | None = None, ) -> None: """Adds a signed transaction with on_complete=ClearState to atc""" - return self.compose_call( + return self.add_method_call( atc, - signer=signer, - sender=sender, - suggested_params=suggested_params, + parameters=transaction_parameters, on_complete=transaction.OnComplete.ClearStateOC, - note=note, - lease=lease, + app_args=app_args, ) def clear_state( self, - *, - signer: TransactionSigner | None = None, - sender: str | None = None, - suggested_params: transaction.SuggestedParams | None = None, - note: bytes | str | None = None, - lease: bytes | None = None, - ) -> TransactionResponse | ABITransactionResponse: + transaction_parameters: CommonCallParameters | CommonCallParametersDict | None = None, + app_args: list[bytes] | None = None, + ) -> TransactionResponse: """Submits a signed transaction with on_complete=ClearState""" atc = AtomicTransactionComposer() self.compose_clear_state( atc, - signer=signer, - sender=sender, - suggested_params=suggested_params, - note=note, - lease=lease, + transaction_parameters=transaction_parameters, + app_args=app_args, ) return self._execute_atc_tr(atc) def get_global_state(self, *, raw: bool = False) -> dict[bytes | str, bytes | str | int]: """gets the global state info for the app id set""" - global_state = cast(dict[str, Any], self.algod_client.application_info(self.app_id)) + global_state = self.algod_client.application_info(self.app_id) + assert isinstance(global_state, dict) return cast( dict[bytes | str, bytes | str | int], _decode_state(global_state.get("params", {}).get("global-state", {}), raw=raw), @@ -962,17 +934,18 @@ def get_local_state(self, account: str | None = None, *, raw: bool = False) -> d if account is None: _, account = self._resolve_signer_sender(self.signer, self.sender) - acct_state = cast(dict[str, Any], self.algod_client.account_application_info(account, self.app_id)) + acct_state = self.algod_client.account_application_info(account, self.app_id) + assert isinstance(acct_state, dict) return cast( dict[bytes | str, bytes | str | int], _decode_state(acct_state.get("app-local-state", {}).get("key-value", {}), raw=raw), ) - def resolve(self, to_resolve: DefaultArgumentDict) -> int | str | bytes: + def resolve(self, to_resolve: au_spec.DefaultArgumentDict) -> int | str | bytes: def _data_check(value: Any) -> int | str | bytes: - if isinstance(value, (str, bytes, bytes)): + if isinstance(value, (int, str, bytes)): return value - raise ValueError("Unexpected type for constant data") + raise ValueError(f"Unexpected type for constant data: {value}") match to_resolve: case {"source": "constant", "data": data}: @@ -987,7 +960,7 @@ def _data_check(value: Any) -> int | str | bytes: case {"source": "abi-method", "data": dict() as method_dict}: method = Method.undictify(method_dict) response = self.call(method) - assert isinstance(response, ABIResult) + assert isinstance(response, ABITransactionResponse) return _data_check(response.return_value) case {"source": source}: @@ -995,14 +968,35 @@ def _data_check(value: Any) -> int | str | bytes: case _: raise TypeError("Unable to interpret default argument specification") + def _check_is_compiled(self) -> tuple[Program, Program]: + if self._approval_program is None or self._clear_program is None: + raise Exception( + "Compiled programs are not available, please provide template_values before creating or updating" + ) + return self._approval_program, self._clear_program + + def _try_dry_run_call(self, method: Method, atc: AtomicTransactionComposer) -> ABITransactionResponse | None: + hints = self._method_hints(method) + if hints and hints.read_only: + dr_req = transaction.create_dryrun(self.algod_client, atc.gather_signatures()) # type: ignore[arg-type] + dr_result = self.algod_client.dryrun(dr_req) # type: ignore[arg-type] + for txn in dr_result["txns"]: + if "app-call-messages" in txn and "REJECT" in txn["app-call-messages"]: + msg = ", ".join(txn["app-call-messages"]) + raise Exception(f"Dryrun for readonly method failed: {msg}") + + method_results = _parse_result({0: method}, dr_result["txns"], atc.tx_ids) + return ABITransactionResponse(**method_results[0].__dict__, confirmed_round=None) + return None + def _load_reference_and_check_app_id(self) -> None: self._load_app_reference() self._check_app_id() - def _load_app_reference(self) -> AppReference | AppMetaData: + def _load_app_reference(self) -> au_deploy.AppReference | au_deploy.AppMetaData: if not self.existing_deployments and self._creator: assert self._indexer_client - self.existing_deployments = get_creator_apps(self._indexer_client, self._creator) + self.existing_deployments = au_deploy.get_creator_apps(self._indexer_client, self._creator) if self.existing_deployments and self.app_id == 0: app = self.existing_deployments.apps.get(self.app_spec.contract.name) @@ -1010,7 +1004,7 @@ def _load_app_reference(self) -> AppReference | AppMetaData: self.app_id = app.app_id return app - return AppReference(self.app_id, self.app_address) + return au_deploy.AppReference(self.app_id, self.app_address) def _check_app_id(self) -> None: if self.app_id == 0: @@ -1026,7 +1020,7 @@ def _resolve_method( abi_method: Method | str | bool | None, args: ABIArgsDict | None, on_complete: transaction.OnComplete, - call_config: CallConfig = CallConfig.CALL, + call_config: au_spec.CallConfig = au_spec.CallConfig.CALL, ) -> Method | None: matches: list[Method | None] = [] match abi_method: @@ -1034,7 +1028,7 @@ def _resolve_method( return self._resolve_abi_method(abi_method) case bool() | None: # find abi method has_bare_config = ( - call_config in _get_call_config(self.app_spec.bare_call_config, on_complete) + call_config in au_deploy.get_call_config(self.app_spec.bare_call_config, on_complete) or on_complete == transaction.OnComplete.ClearStateOC ) abi_methods = self._find_abi_methods(args, on_complete, call_config) @@ -1048,25 +1042,13 @@ def _resolve_method( elif len(matches) > 1: # ambiguous match signatures = ", ".join((m.get_signature() if isinstance(m, Method) else "bare") for m in matches) raise Exception( - f"Could not find an exact method to use for {on_complete} with call_config of {call_config}, " + f"Could not find an exact method to use for {on_complete.name} with call_config of {call_config.name}, " f"specify the exact method using abi_method and args parameters, considered: {signatures}" ) else: # no match - raise Exception(f"Could not find any methods to use for {on_complete} with call_config of {call_config}") - - def _substitute_template_and_compile( - self, - template_values: TemplateValueDict | None, - ) -> tuple[Program, Program]: - template_values = dict(template_values or {}) - clear = replace_template_variables(self.app_spec.clear_program, template_values) - - _check_template_variables(self.app_spec.approval_program, template_values) - approval = replace_template_variables(self.app_spec.approval_program, template_values) - - self._approval_program = Program(approval, self.algod_client) - self._clear_program = Program(clear, self.algod_client) - return self._approval_program, self._clear_program + raise Exception( + f"Could not find any methods to use for {on_complete.name} with call_config of {call_config.name}" + ) def _get_approval_source_map(self) -> SourceMap | None: if self.approval: @@ -1074,44 +1056,37 @@ def _get_approval_source_map(self) -> SourceMap | None: return self.approval_source_map - def _add_method_call( + def add_method_call( self, atc: AtomicTransactionComposer, - method: Method | None = None, + abi_method: Method | str | bool | None = None, abi_args: ABIArgsDict | None = None, app_id: int | None = None, - sender: str | None = None, - signer: TransactionSigner | None = None, - suggested_params: transaction.SuggestedParams | None = None, + parameters: CommonCallParameters | CommonCallParametersDict | None = None, on_complete: transaction.OnComplete = transaction.OnComplete.NoOpOC, local_schema: transaction.StateSchema | None = None, global_schema: transaction.StateSchema | None = None, approval_program: bytes | None = None, clear_program: bytes | None = None, extra_pages: int | None = None, - accounts: list[str] | None = None, - foreign_apps: list[int] | None = None, - foreign_assets: list[int] | None = None, - boxes: Sequence[tuple[int, bytes | bytearray | str | int]] | None = None, - note: bytes | str | None = None, - lease: bytes | str | None = None, - rekey_to: str | None = None, - ) -> AtomicTransactionComposer: + app_args: list[bytes] | None = None, + call_config: au_spec.CallConfig = au_spec.CallConfig.CALL, + ) -> None: """Adds a transaction to the AtomicTransactionComposer passed""" if app_id is None: + self._load_reference_and_check_app_id() app_id = self.app_id - sp = suggested_params or self.suggested_params or self.algod_client.suggested_params() - signer, sender = self._resolve_signer_sender(signer, sender) - if boxes is not None: + parameters = _convert_call_parameters(parameters) + method = self._resolve_method(abi_method, abi_args, on_complete, call_config) + sp = parameters.suggested_params or self.suggested_params or self.algod_client.suggested_params() + signer, sender = self._resolve_signer_sender(parameters.signer, parameters.sender) + if parameters.boxes is not None: # TODO: algosdk actually does this, but it's type hints say otherwise... - encoded_boxes = [(id_, algosdk.encoding.encode_as_bytes(name)) for id_, name in boxes] + encoded_boxes = [(id_, algosdk.encoding.encode_as_bytes(name)) for id_, name in parameters.boxes] else: encoded_boxes = None - if lease is not None: - encoded_lease = lease.encode("utf-8") if isinstance(lease, str) else lease - else: - encoded_lease = None + encoded_lease = parameters.lease.encode("utf-8") if isinstance(parameters.lease, str) else parameters.lease if not method: # not an abi method, treat as a regular call if abi_args: @@ -1128,13 +1103,14 @@ def _add_method_call( global_schema=global_schema, local_schema=local_schema, extra_pages=extra_pages, - accounts=accounts, - foreign_apps=foreign_apps, - foreign_assets=foreign_assets, + accounts=parameters.accounts, + foreign_apps=parameters.foreign_apps, + foreign_assets=parameters.foreign_assets, boxes=encoded_boxes, - note=note, + note=parameters.note, lease=encoded_lease, - rekey_to=rekey_to, + rekey_to=parameters.rekey_to, + app_args=app_args, ), signer=signer, ) @@ -1181,22 +1157,24 @@ def _add_method_call( approval_program=approval_program, clear_program=clear_program, extra_pages=extra_pages or 0, - accounts=accounts, - foreign_apps=foreign_apps, - foreign_assets=foreign_assets, + accounts=parameters.accounts, + foreign_apps=parameters.foreign_apps, + foreign_assets=parameters.foreign_assets, boxes=encoded_boxes, - note=note.encode("utf-8") if isinstance(note, str) else note, + note=parameters.note.encode("utf-8") if isinstance(parameters.note, str) else parameters.note, lease=encoded_lease, - rekey_to=rekey_to, + rekey_to=parameters.rekey_to, ) - return atc - def _method_matches( - self, method: Method, args: ABIArgsDict | None, on_complete: transaction.OnComplete, call_config: CallConfig + self, + method: Method, + args: ABIArgsDict | None, + on_complete: transaction.OnComplete, + call_config: au_spec.CallConfig, ) -> bool: hints = self._method_hints(method) - if call_config not in _get_call_config(hints.call_config, on_complete): + if call_config not in au_deploy.get_call_config(hints.call_config, on_complete): return False method_args = {m.name for m in method.args} provided_args = set(args or {}) | set(hints.default_arguments) @@ -1205,7 +1183,7 @@ def _method_matches( return method_args == provided_args def _find_abi_methods( - self, args: ABIArgsDict | None, on_complete: transaction.OnComplete, call_config: CallConfig + self, args: ABIArgsDict | None, on_complete: transaction.OnComplete, call_config: au_spec.CallConfig ) -> list[Method]: return [ method @@ -1223,27 +1201,17 @@ def _resolve_abi_method(self, method: Method | str) -> Method: else: return method - def _method_hints(self, method: Method) -> MethodHints: + def _method_hints(self, method: Method) -> au_spec.MethodHints: sig = method.get_signature() if sig not in self.app_spec.hints: - return MethodHints() + return au_spec.MethodHints() return self.app_spec.hints[sig] def _execute_atc_tr(self, atc: AtomicTransactionComposer) -> TransactionResponse: - result = self._execute_atc(atc) - if result.abi_results: - return ABITransactionResponse( - tx_id=result.tx_ids[0], - abi_result=result.abi_results[0], - confirmed_round=result.confirmed_round, - ) - else: - return TransactionResponse( - tx_id=result.tx_ids[0], - confirmed_round=result.confirmed_round, - ) + result = self.execute_atc(atc) + return _tr_from_atr(atc, result) - def _execute_atc(self, atc: AtomicTransactionComposer) -> AtomicTransactionResponse: + def execute_atc(self, atc: AtomicTransactionComposer) -> AtomicTransactionResponse: return execute_atc_with_logic_error( atc, self.algod_client, @@ -1269,9 +1237,26 @@ def _resolve_signer_sender( return resolved_signer, resolved_sender +def substitute_template_and_compile( + algod_client: AlgodClient, + app_spec: au_spec.ApplicationSpecification, + template_values: au_deploy.TemplateValueDict, +) -> tuple[Program, Program]: + template_values = dict(template_values or {}) + clear = au_deploy.replace_template_variables(app_spec.clear_program, template_values) + + au_deploy.check_template_variables(app_spec.approval_program, template_values) + approval = au_deploy.replace_template_variables(app_spec.approval_program, template_values) + + return Program(approval, algod_client), Program(clear, algod_client) + + def get_app_id_from_tx_id(algod_client: AlgodClient, tx_id: str) -> int: - result = cast(dict[str, Any], algod_client.pending_transaction_info(tx_id)) - return cast(int, result["application-index"]) + result = algod_client.pending_transaction_info(tx_id) + assert isinstance(result, dict) + app_id = result["application-index"] + assert isinstance(app_id, int) + return app_id def get_next_version(current_version: str) -> str: @@ -1285,16 +1270,93 @@ def replacement(m: re.Match) -> str: return f"{m.group('prefix')}{new_version}{m.group('suffix')}" return re.sub(pattern, replacement, current_version) - raise DeploymentFailedError( + raise au_deploy.DeploymentFailedError( f"Could not auto increment {current_version}, please specify the next version using the version parameter" ) +def execute_atc_with_logic_error( + atc: AtomicTransactionComposer, + algod_client: AlgodClient, + wait_rounds: int = 4, + approval_program: str | None = None, + approval_source_map: SourceMap | None = None, +) -> AtomicTransactionResponse: + try: + return atc.execute(algod_client, wait_rounds=wait_rounds) + except Exception as ex: + if approval_source_map and approval_program: + logic_error_data = parse_logic_error(str(ex)) + if logic_error_data is not None: + raise LogicError( + logic_error=ex, + program=approval_program, + source_map=approval_source_map, + **logic_error_data, + ) from ex + raise ex + + +def _create_metadata( + app_spec_note: au_deploy.AppDeployMetaData, + app_id: int, + created_round: int, + updated_round: int | None = None, + original_metadata: au_deploy.AppDeployMetaData | None = None, +) -> au_deploy.AppMetaData: + app_metadata = au_deploy.AppMetaData( + app_id=app_id, + app_address=get_application_address(app_id), + created_metadata=original_metadata or app_spec_note, + created_round=created_round, + updated_round=updated_round or created_round, + **app_spec_note.__dict__, + deleted=False, + ) + return app_metadata + + +def _convert_call_parameters(args: CommonCallParameters | CommonCallParametersDict | None) -> CreateCallParameters: + _args = dataclasses.asdict(args) if isinstance(args, CommonCallParameters) else (args or {}) + return CreateCallParameters(**_args) + + +def _convert_deploy_args( + _args: ABICallArgs | ABICallArgsDict | None, + note: au_deploy.AppDeployMetaData, + signer: TransactionSigner | None, + sender: str | None, +) -> tuple[Method | str | bool | None, ABIArgsDict, CreateCallParameters]: + args = dataclasses.asdict(_args) if isinstance(_args, ABICallArgs) else (_args or {}) + + # return most derived type, unused parameters are ignored + parameters = CreateCallParameters( + note=note.encode(), + signer=signer, + sender=sender, + suggested_params=args.get("suggested_params"), + lease=args.get("lease"), + accounts=args.get("accounts"), + foreign_assets=args.get("foreign_assets"), + foreign_apps=args.get("foreign_apps"), + boxes=args.get("boxes"), + rekey_to=args.get("rekey_to"), + extra_pages=args.get("extra_pages"), + on_complete=args.get("on_complete"), + ) + + return args.get("method"), args.get("args") or {}, parameters + + def _get_sender_from_signer(signer: TransactionSigner) -> str: if isinstance(signer, AccountTransactionSigner): - return cast(str, address_from_private_key(signer.private_key)) # type: ignore[no-untyped-call] + sender = address_from_private_key(signer.private_key) # type: ignore[no-untyped-call] + assert isinstance(sender, str) + return sender elif isinstance(signer, MultisigTransactionSigner): - return cast(str, signer.msig.address()) # type: ignore[no-untyped-call] + sender = signer.msig.address() # type: ignore[no-untyped-call] + assert isinstance(sender, str) + return sender elif isinstance(signer, LogicSigTransactionSigner): return signer.lsig.address() else: @@ -1373,25 +1435,6 @@ def _increment_version(version: str) -> str: return ".".join(str(x) for x in split) -def _get_call_config(method_config: MethodConfigDict, on_complete: transaction.OnComplete) -> CallConfig: - def get(key: OnCompleteActionName) -> CallConfig: - return method_config.get(key, CallConfig.NEVER) - - match on_complete: - case transaction.OnComplete.NoOpOC: - return get("no_op") - case transaction.OnComplete.UpdateApplicationOC: - return get("update_application") - case transaction.OnComplete.DeleteApplicationOC: - return get("delete_application") - case transaction.OnComplete.OptInOC: - return get("opt_in") - case transaction.OnComplete.CloseOutOC: - return get("close_out") - case transaction.OnComplete.ClearStateOC: - return get("clear_state") - - def _str_or_hex(v: bytes) -> str: decoded: str try: @@ -1428,33 +1471,21 @@ def _decode_state(state: list[dict[str, Any]], *, raw: bool = False) -> dict[str return decoded_state -def _get_deploy_control( - app_spec: ApplicationSpecification, template_var: str, on_complete: transaction.OnComplete -) -> bool | None: - if template_var not in _strip_comments(app_spec.approval_program): - return None - return _get_call_config(app_spec.bare_call_config, on_complete) != CallConfig.NEVER or any( - h for h in app_spec.hints.values() if _get_call_config(h.call_config, on_complete) != CallConfig.NEVER - ) - - -def execute_atc_with_logic_error( - atc: AtomicTransactionComposer, - algod_client: AlgodClient, - wait_rounds: int = 4, - approval_program: str | None = None, - approval_source_map: SourceMap | None = None, -) -> AtomicTransactionResponse: - try: - return atc.execute(algod_client, wait_rounds=wait_rounds) - except Exception as ex: - if approval_source_map and approval_program: - logic_error_data = parse_logic_error(str(ex)) - if logic_error_data is not None: - raise LogicError( - logic_error=ex, - program=approval_program, - source_map=approval_source_map, - **logic_error_data, - ) from ex - raise ex +def _tr_from_atr( + atc: AtomicTransactionComposer, result: AtomicTransactionResponse, transaction_index: int = 0 +) -> TransactionResponse: + if result.abi_results and transaction_index in atc.method_dict: # expecting an ABI result + abi_index = 0 + # count how many of the earlier transactions were also ABI + for index in range(transaction_index): + if index in atc.method_dict: + abi_index += 1 + return ABITransactionResponse( + **result.abi_results[abi_index].__dict__, + confirmed_round=result.confirmed_round, + ) + else: + return TransactionResponse( + tx_id=result.tx_ids[transaction_index], + confirmed_round=result.confirmed_round, + ) diff --git a/src/algokit_utils/application_specification.py b/src/algokit_utils/application_specification.py index ff31aa9..5a85463 100644 --- a/src/algokit_utils/application_specification.py +++ b/src/algokit_utils/application_specification.py @@ -97,7 +97,7 @@ def undictify(data: dict[str, Any]) -> "MethodHints": def _encode_method_config(mc: MethodConfigDict) -> dict[str, str | None]: - return {k: v.name for k, v in mc.items() if v != CallConfig.NEVER} + return {k: mc[k].name for k in sorted(mc) if mc[k] != CallConfig.NEVER} def _decode_method_config(data: dict[OnCompleteActionName, Any]) -> MethodConfigDict: diff --git a/src/algokit_utils/app.py b/src/algokit_utils/deploy.py similarity index 52% rename from src/algokit_utils/app.py rename to src/algokit_utils/deploy.py index 0269ab6..3f855a5 100644 --- a/src/algokit_utils/app.py +++ b/src/algokit_utils/deploy.py @@ -3,13 +3,22 @@ import json import logging import re -from collections.abc import Mapping +from collections.abc import Callable, Iterable, Mapping +from enum import Enum +from algosdk import transaction from algosdk.logic import get_application_address from algosdk.transaction import StateSchema +from algosdk.v2client.algod import AlgodClient from algosdk.v2client.indexer import IndexerClient -from algokit_utils.models import Account +from algokit_utils.application_specification import ( + ApplicationSpecification, + CallConfig, + MethodConfigDict, + OnCompleteActionName, +) +from algokit_utils.models import Account, TransactionResponse __all__ = [ "UPDATABLE_TEMPLATE_NAME", @@ -20,6 +29,12 @@ "AppDeployMetaData", "AppMetaData", "AppLookup", + "DeployResponse", + "OnUpdate", + "OnSchemaBreak", + "OperationPerformed", + "TemplateValueDict", + "check_app_and_deploy", "get_creator_apps", "replace_template_variables", ] @@ -129,9 +144,9 @@ def get_creator_apps(indexer: IndexerClient, creator_account: Account | str) -> if t["application-transaction"]["application-id"] == 0 and t["sender"] == creator_address ) - def sort_by_round(transaction: dict) -> tuple[int, int]: - confirmed = transaction["confirmed-round"] - offset = transaction["intra-round-offset"] + def sort_by_round(txn: dict) -> tuple[int, int]: + confirmed = txn["confirmed-round"] + offset = txn["intra-round-offset"] return confirmed, offset transactions.sort(key=sort_by_round, reverse=True) @@ -168,19 +183,51 @@ def parse_note(metadata_b64: str | None) -> AppDeployMetaData | None: return AppLookup(creator_address, apps) -# TODO: put these somewhere more useful def _state_schema(schema: dict[str, int]) -> StateSchema: return StateSchema(schema.get("num-uint", 0), schema.get("num-byte-slice", 0)) # type: ignore[no-untyped-call] -def _schema_is_less(a: StateSchema, b: StateSchema) -> bool: - return bool(a.num_uints < b.num_uints or a.num_byte_slices < b.num_byte_slices) +def _describe_schema_breaks(prefix: str, from_schema: StateSchema, to_schema: StateSchema) -> Iterable[str]: + if to_schema.num_uints > from_schema.num_uints: + yield f"{prefix} uints increased from {from_schema.num_uints} to {to_schema.num_uints}" + if to_schema.num_byte_slices > from_schema.num_byte_slices: + yield f"{prefix} byte slices increased from {from_schema.num_byte_slices} to {to_schema.num_byte_slices}" -def _schema_str(global_schema: StateSchema, local_schema: StateSchema) -> str: - return ( - f"Global: uints={global_schema.num_uints}, byte_slices={global_schema.num_byte_slices}, " - f"Local: uints={local_schema.num_uints}, byte_slices={local_schema.num_byte_slices}" +@dataclasses.dataclass(kw_only=True) +class AppChanges: + app_updated: bool + schema_breaking_change: bool + schema_change_description: str | None + + +def check_for_app_changes( + algod_client: AlgodClient, + new_approval: bytes, + new_clear: bytes, + new_global_schema: StateSchema, + new_local_schema: StateSchema, + app_id: int, +) -> AppChanges: + application_info = algod_client.application_info(app_id) + assert isinstance(application_info, dict) + application_create_params = application_info["params"] + + current_approval = base64.b64decode(application_create_params["approval-program"]) + current_clear = base64.b64decode(application_create_params["clear-state-program"]) + current_global_schema = _state_schema(application_create_params["global-state-schema"]) + current_local_schema = _state_schema(application_create_params["local-state-schema"]) + + app_updated = current_approval != new_approval or current_clear != new_clear + + schema_changes: list[str] = [] + schema_changes.extend(_describe_schema_breaks("Global", current_global_schema, new_global_schema)) + schema_changes.extend(_describe_schema_breaks("Local", current_local_schema, new_local_schema)) + + return AppChanges( + app_updated=app_updated, + schema_breaking_change=bool(schema_changes), + schema_change_description=", ".join(schema_changes), ) @@ -203,7 +250,7 @@ def replacement(m: re.Match) -> str: return result, match_count -def _add_deploy_template_variables( +def add_deploy_template_variables( template_values: TemplateValueDict, allow_update: bool | None, allow_delete: bool | None ) -> None: if allow_update is not None: @@ -217,7 +264,7 @@ def _strip_comments(program: str) -> str: return "\n".join(lines) -def _check_template_variables(approval_program: str, template_values: TemplateValueDict) -> None: +def check_template_variables(approval_program: str, template_values: TemplateValueDict) -> None: approval_program = _strip_comments(approval_program) if UPDATABLE_TEMPLATE_NAME in approval_program and _UPDATABLE not in template_values: raise DeploymentFailedError( @@ -259,3 +306,140 @@ def replace_template_variables(program: str, template_values: TemplateValueMappi program_lines, matches = _replace_template_variable(program_lines, template_variable_name, value) return "\n".join(program_lines) + + +def has_template_vars(app_spec: ApplicationSpecification) -> bool: + return "TMPL_" in _strip_comments(app_spec.approval_program) or "TMPL_" in _strip_comments(app_spec.clear_program) + + +def get_deploy_control( + app_spec: ApplicationSpecification, template_var: str, on_complete: transaction.OnComplete +) -> bool | None: + if template_var not in _strip_comments(app_spec.approval_program): + return None + return get_call_config(app_spec.bare_call_config, on_complete) != CallConfig.NEVER or any( + h for h in app_spec.hints.values() if get_call_config(h.call_config, on_complete) != CallConfig.NEVER + ) + + +def get_call_config(method_config: MethodConfigDict, on_complete: transaction.OnComplete) -> CallConfig: + def get(key: OnCompleteActionName) -> CallConfig: + return method_config.get(key, CallConfig.NEVER) + + match on_complete: + case transaction.OnComplete.NoOpOC: + return get("no_op") + case transaction.OnComplete.UpdateApplicationOC: + return get("update_application") + case transaction.OnComplete.DeleteApplicationOC: + return get("delete_application") + case transaction.OnComplete.OptInOC: + return get("opt_in") + case transaction.OnComplete.CloseOutOC: + return get("close_out") + case transaction.OnComplete.ClearStateOC: + return get("clear_state") + + +class OnUpdate(Enum): + Fail = 0 + UpdateApp = 1 + ReplaceApp = 2 + # TODO: AppendApp + + +class OnSchemaBreak(Enum): + Fail = 0 + ReplaceApp = 2 + + +class OperationPerformed(Enum): + Nothing = 0 + Create = 1 + Update = 2 + Replace = 3 + + +@dataclasses.dataclass(kw_only=True) +class DeployResponse: + app: AppMetaData + create_response: TransactionResponse | None = None + delete_response: TransactionResponse | None = None + update_response: TransactionResponse | None = None + action_taken: OperationPerformed = OperationPerformed.Nothing + + +def check_app_and_deploy( + app: AppMetaData, + app_changes: AppChanges, + on_schema_break: OnSchemaBreak, + on_update: OnUpdate, + update_app: Callable[[], DeployResponse], + create_and_delete_app: Callable[[], DeployResponse], +) -> DeployResponse: + if app_changes.schema_breaking_change: + logger.warning(f"Detected a breaking app schema change: {app_changes.schema_change_description}") + + if on_schema_break == OnSchemaBreak.Fail: + raise DeploymentFailedError( + "Schema break detected and on_schema_break=OnSchemaBreak.Fail, stopping deployment. " + "If you want to try deleting and recreating the app then " + "re-run with on_schema_break=OnSchemaBreak.ReplaceApp" + ) + if app.deletable: + logger.info( + "App is deletable and on_schema_break=ReplaceApp, will attempt to create new app and delete old app" + ) + elif app.deletable is False: + logger.warning( + "App is not deletable but on_schema_break=ReplaceApp, " + "will attempt to delete app, delete will most likely fail" + ) + else: + logger.warning( + "Cannot determine if App is deletable but on_schema_break=ReplaceApp, " "will attempt to delete app" + ) + return create_and_delete_app() + elif app_changes.app_updated: + logger.info(f"Detected a TEAL update in app id {app.app_id}") + + if on_update == OnUpdate.Fail: + raise DeploymentFailedError( + "Update detected and on_update=Fail, stopping deployment. " + "If you want to try updating the app then re-run with on_update=UpdateApp" + ) + if app.updatable and on_update == OnUpdate.UpdateApp: + logger.info("App is updatable and on_update=UpdateApp, will update app") + return update_app() + elif app.updatable and on_update == OnUpdate.ReplaceApp: + logger.warning( + "App is updatable but on_update=ReplaceApp, will attempt to create new app and delete old app" + ) + return create_and_delete_app() + elif on_update == OnUpdate.ReplaceApp: + if app.updatable is False: + logger.warning( + "App is not updatable and on_update=ReplaceApp, " + "will attempt to create new app and delete old app" + ) + else: + logger.warning( + "Cannot determine if App is updatable and on_update=ReplaceApp, " + "will attempt to create new app and delete old app" + ) + return create_and_delete_app() + else: + if app.updatable is False: + logger.warning( + "App is not updatable but on_update=UpdateApp, " + "will attempt to update app, update will most likely fail" + ) + else: + logger.warning( + "Cannot determine if App is updatable and on_update=UpdateApp, " "will attempt to update app" + ) + return update_app() + + logger.info("No detected changes in app, nothing to do.") + + return DeployResponse(app=app) diff --git a/src/algokit_utils/logic_error.py b/src/algokit_utils/logic_error.py index b796d0d..836ab58 100644 --- a/src/algokit_utils/logic_error.py +++ b/src/algokit_utils/logic_error.py @@ -6,6 +6,7 @@ __all__ = [ "LogicError", + "parse_logic_error", ] LOGIC_ERROR = ( diff --git a/src/algokit_utils/models.py b/src/algokit_utils/models.py index 2908216..4cfc049 100644 --- a/src/algokit_utils/models.py +++ b/src/algokit_utils/models.py @@ -1,7 +1,25 @@ import dataclasses +from typing import Any + +from algosdk.abi import Method @dataclasses.dataclass class Account: private_key: str address: str + + +@dataclasses.dataclass(kw_only=True) +class TransactionResponse: + tx_id: str + confirmed_round: int | None + + +@dataclasses.dataclass(kw_only=True) +class ABITransactionResponse(TransactionResponse): + raw_value: bytes + return_value: Any + decode_error: Exception | None + tx_info: dict + method: Method diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/app_client_test.json b/tests/app_client_test.json index 4bb5273..9cb1c32 100644 --- a/tests/app_client_test.json +++ b/tests/app_client_test.json @@ -5,18 +5,27 @@ "update_application": "CALL" } }, + "update_args(string)void": { + "call_config": { + "update_application": "CALL" + } + }, "delete()void": { "call_config": { "delete_application": "CALL" } }, + "delete_args(string)void": { + "call_config": { + "delete_application": "CALL" + } + }, "create_opt_in()void": { "call_config": { "opt_in": "CREATE" } }, "update_greeting(string)void": { - "read_only": true, "call_config": { "no_op": "CALL" } @@ -36,11 +45,42 @@ "call_config": { "no_op": "CALL" } + }, + "hello_remember(string)string": { + "call_config": { + "no_op": "CALL" + } + }, + "get_last()string": { + "read_only": true, + "call_config": { + "no_op": "CALL" + } + }, + "opt_in()void": { + "call_config": { + "opt_in": "CALL" + } + }, + "opt_in_args(string)void": { + "call_config": { + "opt_in": "CALL" + } + }, + "close_out()void": { + "call_config": { + "close_out": "CALL" + } + }, + "close_out_args(string)void": { + "call_config": { + "close_out": "CALL" + } } }, "source": { - "approval": "I3ByYWdtYSB2ZXJzaW9uIDgKaW50Y2Jsb2NrIDAgMQpieXRlY2Jsb2NrIDB4Njc3MjY1NjU3NDY5NmU2Nwp0eG4gTnVtQXBwQXJncwppbnRjXzAgLy8gMAo9PQpibnogbWFpbl9sMTYKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMApwdXNoYnl0ZXMgMHhhMGU4MTg3MiAvLyAidXBkYXRlKCl2b2lkIgo9PQpibnogbWFpbl9sMTUKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMApwdXNoYnl0ZXMgMHgyNDM3OGQzYyAvLyAiZGVsZXRlKCl2b2lkIgo9PQpibnogbWFpbl9sMTQKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMApwdXNoYnl0ZXMgMHg4YmRmOWViMCAvLyAiY3JlYXRlX29wdF9pbigpdm9pZCIKPT0KYm56IG1haW5fbDEzCnR4bmEgQXBwbGljYXRpb25BcmdzIDAKcHVzaGJ5dGVzIDB4MDA1NWYwMDYgLy8gInVwZGF0ZV9ncmVldGluZyhzdHJpbmcpdm9pZCIKPT0KYm56IG1haW5fbDEyCnR4bmEgQXBwbGljYXRpb25BcmdzIDAKcHVzaGJ5dGVzIDB4NGM1YzYxYmEgLy8gImNyZWF0ZSgpdm9pZCIKPT0KYm56IG1haW5fbDExCnR4bmEgQXBwbGljYXRpb25BcmdzIDAKcHVzaGJ5dGVzIDB4ZDE0NTRjNzggLy8gImNyZWF0ZV9hcmdzKHN0cmluZyl2b2lkIgo9PQpibnogbWFpbl9sMTAKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMApwdXNoYnl0ZXMgMHgwMmJlY2UxMSAvLyAiaGVsbG8oc3RyaW5nKXN0cmluZyIKPT0KYm56IG1haW5fbDkKZXJyCm1haW5fbDk6CnR4biBPbkNvbXBsZXRpb24KaW50Y18wIC8vIE5vT3AKPT0KdHhuIEFwcGxpY2F0aW9uSUQKaW50Y18wIC8vIDAKIT0KJiYKYXNzZXJ0CnR4bmEgQXBwbGljYXRpb25BcmdzIDEKY2FsbHN1YiBoZWxsb183CnN0b3JlIDAKcHVzaGJ5dGVzIDB4MTUxZjdjNzUgLy8gMHgxNTFmN2M3NQpsb2FkIDAKY29uY2F0CmxvZwppbnRjXzEgLy8gMQpyZXR1cm4KbWFpbl9sMTA6CnR4biBPbkNvbXBsZXRpb24KaW50Y18wIC8vIE5vT3AKPT0KdHhuIEFwcGxpY2F0aW9uSUQKaW50Y18wIC8vIDAKPT0KJiYKYXNzZXJ0CnR4bmEgQXBwbGljYXRpb25BcmdzIDEKY2FsbHN1YiBjcmVhdGVhcmdzXzYKaW50Y18xIC8vIDEKcmV0dXJuCm1haW5fbDExOgp0eG4gT25Db21wbGV0aW9uCmludGNfMCAvLyBOb09wCj09CnR4biBBcHBsaWNhdGlvbklECmludGNfMCAvLyAwCj09CiYmCmFzc2VydApjYWxsc3ViIGNyZWF0ZV80CmludGNfMSAvLyAxCnJldHVybgptYWluX2wxMjoKdHhuIE9uQ29tcGxldGlvbgppbnRjXzAgLy8gTm9PcAo9PQp0eG4gQXBwbGljYXRpb25JRAppbnRjXzAgLy8gMAohPQomJgphc3NlcnQKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMQpjYWxsc3ViIHVwZGF0ZWdyZWV0aW5nXzMKaW50Y18xIC8vIDEKcmV0dXJuCm1haW5fbDEzOgp0eG4gT25Db21wbGV0aW9uCmludGNfMSAvLyBPcHRJbgo9PQp0eG4gQXBwbGljYXRpb25JRAppbnRjXzAgLy8gMAo9PQomJgphc3NlcnQKY2FsbHN1YiBjcmVhdGVvcHRpbl8yCmludGNfMSAvLyAxCnJldHVybgptYWluX2wxNDoKdHhuIE9uQ29tcGxldGlvbgpwdXNoaW50IDUgLy8gRGVsZXRlQXBwbGljYXRpb24KPT0KdHhuIEFwcGxpY2F0aW9uSUQKaW50Y18wIC8vIDAKIT0KJiYKYXNzZXJ0CmNhbGxzdWIgZGVsZXRlXzEKaW50Y18xIC8vIDEKcmV0dXJuCm1haW5fbDE1Ogp0eG4gT25Db21wbGV0aW9uCnB1c2hpbnQgNCAvLyBVcGRhdGVBcHBsaWNhdGlvbgo9PQp0eG4gQXBwbGljYXRpb25JRAppbnRjXzAgLy8gMAohPQomJgphc3NlcnQKY2FsbHN1YiB1cGRhdGVfMAppbnRjXzEgLy8gMQpyZXR1cm4KbWFpbl9sMTY6CnR4biBPbkNvbXBsZXRpb24KaW50Y18wIC8vIE5vT3AKPT0KYm56IG1haW5fbDE4CmVycgptYWluX2wxODoKdHhuIEFwcGxpY2F0aW9uSUQKaW50Y18wIC8vIDAKPT0KYXNzZXJ0CmNhbGxzdWIgY3JlYXRlYmFyZV81CmludGNfMSAvLyAxCnJldHVybgoKLy8gdXBkYXRlCnVwZGF0ZV8wOgpwcm90byAwIDAKdHhuIFNlbmRlcgpnbG9iYWwgQ3JlYXRvckFkZHJlc3MKPT0KLy8gdW5hdXRob3JpemVkCmFzc2VydApwdXNoaW50IFRNUExfVVBEQVRBQkxFIC8vIFRNUExfVVBEQVRBQkxFCi8vIENoZWNrIGFwcCBpcyB1cGRhdGFibGUKYXNzZXJ0CnJldHN1YgoKLy8gZGVsZXRlCmRlbGV0ZV8xOgpwcm90byAwIDAKdHhuIFNlbmRlcgpnbG9iYWwgQ3JlYXRvckFkZHJlc3MKPT0KLy8gdW5hdXRob3JpemVkCmFzc2VydApwdXNoaW50IFRNUExfREVMRVRBQkxFIC8vIFRNUExfREVMRVRBQkxFCi8vIENoZWNrIGFwcCBpcyBkZWxldGFibGUKYXNzZXJ0CnJldHN1YgoKLy8gY3JlYXRlX29wdF9pbgpjcmVhdGVvcHRpbl8yOgpwcm90byAwIDAKYnl0ZWNfMCAvLyAiZ3JlZXRpbmciCnB1c2hieXRlcyAweDRmNzA3NDIwNDk2ZSAvLyAiT3B0IEluIgphcHBfZ2xvYmFsX3B1dApyZXRzdWIKCi8vIHVwZGF0ZV9ncmVldGluZwp1cGRhdGVncmVldGluZ18zOgpwcm90byAxIDAKYnl0ZWNfMCAvLyAiZ3JlZXRpbmciCmZyYW1lX2RpZyAtMQpleHRyYWN0IDIgMAphcHBfZ2xvYmFsX3B1dApyZXRzdWIKCi8vIGNyZWF0ZQpjcmVhdGVfNDoKcHJvdG8gMCAwCmJ5dGVjXzAgLy8gImdyZWV0aW5nIgpwdXNoYnl0ZXMgMHg0ODY1NmM2YzZmMjA0MTQyNDkgLy8gIkhlbGxvIEFCSSIKYXBwX2dsb2JhbF9wdXQKcmV0c3ViCgovLyBjcmVhdGVfYmFyZQpjcmVhdGViYXJlXzU6CnByb3RvIDAgMApieXRlY18wIC8vICJncmVldGluZyIKcHVzaGJ5dGVzIDB4NDg2NTZjNmM2ZjIwNDI2MTcyNjUgLy8gIkhlbGxvIEJhcmUiCmFwcF9nbG9iYWxfcHV0CnJldHN1YgoKLy8gY3JlYXRlX2FyZ3MKY3JlYXRlYXJnc182Ogpwcm90byAxIDAKYnl0ZWNfMCAvLyAiZ3JlZXRpbmciCmZyYW1lX2RpZyAtMQpleHRyYWN0IDIgMAphcHBfZ2xvYmFsX3B1dApyZXRzdWIKCi8vIGhlbGxvCmhlbGxvXzc6CnByb3RvIDEgMQpwdXNoYnl0ZXMgMHggLy8gIiIKYnl0ZWNfMCAvLyAiZ3JlZXRpbmciCmFwcF9nbG9iYWxfZ2V0CnB1c2hieXRlcyAweDJjMjAgLy8gIiwgIgpjb25jYXQKZnJhbWVfZGlnIC0xCmV4dHJhY3QgMiAwCmNvbmNhdApmcmFtZV9idXJ5IDAKZnJhbWVfZGlnIDAKbGVuCml0b2IKZXh0cmFjdCA2IDAKZnJhbWVfZGlnIDAKY29uY2F0CmZyYW1lX2J1cnkgMApyZXRzdWI=", - "clear": "I3ByYWdtYSB2ZXJzaW9uIDgKcHVzaGludCAwIC8vIDAKcmV0dXJu" + "approval": "I3ByYWdtYSB2ZXJzaW9uIDgKaW50Y2Jsb2NrIDEgMCAyIDUgVE1QTF9VUERBVEFCTEUgVE1QTF9ERUxFVEFCTEUKYnl0ZWNibG9jayAweDY3NzI2NTY1NzQ2OTZlNjcgMHg2YzYxNzM3NCAweDU5NjU3MyAweDE1MWY3Yzc1IDB4IDB4MmMyMAp0eG4gTnVtQXBwQXJncwppbnRjXzEgLy8gMAo9PQpibnogbWFpbl9sMzIKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMApwdXNoYnl0ZXMgMHhhMGU4MTg3MiAvLyAidXBkYXRlKCl2b2lkIgo9PQpibnogbWFpbl9sMzEKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMApwdXNoYnl0ZXMgMHg3ZDA4NTE4YiAvLyAidXBkYXRlX2FyZ3Moc3RyaW5nKXZvaWQiCj09CmJueiBtYWluX2wzMAp0eG5hIEFwcGxpY2F0aW9uQXJncyAwCnB1c2hieXRlcyAweDI0Mzc4ZDNjIC8vICJkZWxldGUoKXZvaWQiCj09CmJueiBtYWluX2wyOQp0eG5hIEFwcGxpY2F0aW9uQXJncyAwCnB1c2hieXRlcyAweDU4NjFiYjUwIC8vICJkZWxldGVfYXJncyhzdHJpbmcpdm9pZCIKPT0KYm56IG1haW5fbDI4CnR4bmEgQXBwbGljYXRpb25BcmdzIDAKcHVzaGJ5dGVzIDB4OGJkZjllYjAgLy8gImNyZWF0ZV9vcHRfaW4oKXZvaWQiCj09CmJueiBtYWluX2wyNwp0eG5hIEFwcGxpY2F0aW9uQXJncyAwCnB1c2hieXRlcyAweDAwNTVmMDA2IC8vICJ1cGRhdGVfZ3JlZXRpbmcoc3RyaW5nKXZvaWQiCj09CmJueiBtYWluX2wyNgp0eG5hIEFwcGxpY2F0aW9uQXJncyAwCnB1c2hieXRlcyAweDRjNWM2MWJhIC8vICJjcmVhdGUoKXZvaWQiCj09CmJueiBtYWluX2wyNQp0eG5hIEFwcGxpY2F0aW9uQXJncyAwCnB1c2hieXRlcyAweGQxNDU0Yzc4IC8vICJjcmVhdGVfYXJncyhzdHJpbmcpdm9pZCIKPT0KYm56IG1haW5fbDI0CnR4bmEgQXBwbGljYXRpb25BcmdzIDAKcHVzaGJ5dGVzIDB4MDJiZWNlMTEgLy8gImhlbGxvKHN0cmluZylzdHJpbmciCj09CmJueiBtYWluX2wyMwp0eG5hIEFwcGxpY2F0aW9uQXJncyAwCnB1c2hieXRlcyAweGJjMWMxZGQ0IC8vICJoZWxsb19yZW1lbWJlcihzdHJpbmcpc3RyaW5nIgo9PQpibnogbWFpbl9sMjIKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMApwdXNoYnl0ZXMgMHhhOWFlNzYyNyAvLyAiZ2V0X2xhc3QoKXN0cmluZyIKPT0KYm56IG1haW5fbDIxCnR4bmEgQXBwbGljYXRpb25BcmdzIDAKcHVzaGJ5dGVzIDB4MzBjNmQ1OGEgLy8gIm9wdF9pbigpdm9pZCIKPT0KYm56IG1haW5fbDIwCnR4bmEgQXBwbGljYXRpb25BcmdzIDAKcHVzaGJ5dGVzIDB4MjJjN2RlZGEgLy8gIm9wdF9pbl9hcmdzKHN0cmluZyl2b2lkIgo9PQpibnogbWFpbl9sMTkKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMApwdXNoYnl0ZXMgMHgxNjU4YWEyZiAvLyAiY2xvc2Vfb3V0KCl2b2lkIgo9PQpibnogbWFpbl9sMTgKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMApwdXNoYnl0ZXMgMHhkZTg0ZDlhZCAvLyAiY2xvc2Vfb3V0X2FyZ3Moc3RyaW5nKXZvaWQiCj09CmJueiBtYWluX2wxNwplcnIKbWFpbl9sMTc6CnR4biBPbkNvbXBsZXRpb24KaW50Y18yIC8vIENsb3NlT3V0Cj09CnR4biBBcHBsaWNhdGlvbklECmludGNfMSAvLyAwCiE9CiYmCmFzc2VydAp0eG5hIEFwcGxpY2F0aW9uQXJncyAxCmNhbGxzdWIgY2xvc2VvdXRhcmdzXzE5CmludGNfMCAvLyAxCnJldHVybgptYWluX2wxODoKdHhuIE9uQ29tcGxldGlvbgppbnRjXzIgLy8gQ2xvc2VPdXQKPT0KdHhuIEFwcGxpY2F0aW9uSUQKaW50Y18xIC8vIDAKIT0KJiYKYXNzZXJ0CmNhbGxzdWIgY2xvc2VvdXRfMTcKaW50Y18wIC8vIDEKcmV0dXJuCm1haW5fbDE5Ogp0eG4gT25Db21wbGV0aW9uCmludGNfMCAvLyBPcHRJbgo9PQp0eG4gQXBwbGljYXRpb25JRAppbnRjXzEgLy8gMAohPQomJgphc3NlcnQKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMQpjYWxsc3ViIG9wdGluYXJnc18xNgppbnRjXzAgLy8gMQpyZXR1cm4KbWFpbl9sMjA6CnR4biBPbkNvbXBsZXRpb24KaW50Y18wIC8vIE9wdEluCj09CnR4biBBcHBsaWNhdGlvbklECmludGNfMSAvLyAwCiE9CiYmCmFzc2VydApjYWxsc3ViIG9wdGluXzE0CmludGNfMCAvLyAxCnJldHVybgptYWluX2wyMToKdHhuIE9uQ29tcGxldGlvbgppbnRjXzEgLy8gTm9PcAo9PQp0eG4gQXBwbGljYXRpb25JRAppbnRjXzEgLy8gMAohPQomJgphc3NlcnQKY2FsbHN1YiBnZXRsYXN0XzEzCnN0b3JlIDIKYnl0ZWNfMyAvLyAweDE1MWY3Yzc1CmxvYWQgMgpjb25jYXQKbG9nCmludGNfMCAvLyAxCnJldHVybgptYWluX2wyMjoKdHhuIE9uQ29tcGxldGlvbgppbnRjXzEgLy8gTm9PcAo9PQp0eG4gQXBwbGljYXRpb25JRAppbnRjXzEgLy8gMAohPQomJgphc3NlcnQKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMQpjYWxsc3ViIGhlbGxvcmVtZW1iZXJfMTIKc3RvcmUgMQpieXRlY18zIC8vIDB4MTUxZjdjNzUKbG9hZCAxCmNvbmNhdApsb2cKaW50Y18wIC8vIDEKcmV0dXJuCm1haW5fbDIzOgp0eG4gT25Db21wbGV0aW9uCmludGNfMSAvLyBOb09wCj09CnR4biBBcHBsaWNhdGlvbklECmludGNfMSAvLyAwCiE9CiYmCmFzc2VydAp0eG5hIEFwcGxpY2F0aW9uQXJncyAxCmNhbGxzdWIgaGVsbG9fMTEKc3RvcmUgMApieXRlY18zIC8vIDB4MTUxZjdjNzUKbG9hZCAwCmNvbmNhdApsb2cKaW50Y18wIC8vIDEKcmV0dXJuCm1haW5fbDI0Ogp0eG4gT25Db21wbGV0aW9uCmludGNfMSAvLyBOb09wCj09CnR4biBBcHBsaWNhdGlvbklECmludGNfMSAvLyAwCj09CiYmCmFzc2VydAp0eG5hIEFwcGxpY2F0aW9uQXJncyAxCmNhbGxzdWIgY3JlYXRlYXJnc18xMAppbnRjXzAgLy8gMQpyZXR1cm4KbWFpbl9sMjU6CnR4biBPbkNvbXBsZXRpb24KaW50Y18xIC8vIE5vT3AKPT0KdHhuIEFwcGxpY2F0aW9uSUQKaW50Y18xIC8vIDAKPT0KJiYKYXNzZXJ0CmNhbGxzdWIgY3JlYXRlXzkKaW50Y18wIC8vIDEKcmV0dXJuCm1haW5fbDI2Ogp0eG4gT25Db21wbGV0aW9uCmludGNfMSAvLyBOb09wCj09CnR4biBBcHBsaWNhdGlvbklECmludGNfMSAvLyAwCiE9CiYmCmFzc2VydAp0eG5hIEFwcGxpY2F0aW9uQXJncyAxCmNhbGxzdWIgdXBkYXRlZ3JlZXRpbmdfNwppbnRjXzAgLy8gMQpyZXR1cm4KbWFpbl9sMjc6CnR4biBPbkNvbXBsZXRpb24KaW50Y18wIC8vIE9wdEluCj09CnR4biBBcHBsaWNhdGlvbklECmludGNfMSAvLyAwCj09CiYmCmFzc2VydApjYWxsc3ViIGNyZWF0ZW9wdGluXzYKaW50Y18wIC8vIDEKcmV0dXJuCm1haW5fbDI4Ogp0eG4gT25Db21wbGV0aW9uCmludGNfMyAvLyBEZWxldGVBcHBsaWNhdGlvbgo9PQp0eG4gQXBwbGljYXRpb25JRAppbnRjXzEgLy8gMAohPQomJgphc3NlcnQKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMQpjYWxsc3ViIGRlbGV0ZWFyZ3NfNQppbnRjXzAgLy8gMQpyZXR1cm4KbWFpbl9sMjk6CnR4biBPbkNvbXBsZXRpb24KaW50Y18zIC8vIERlbGV0ZUFwcGxpY2F0aW9uCj09CnR4biBBcHBsaWNhdGlvbklECmludGNfMSAvLyAwCiE9CiYmCmFzc2VydApjYWxsc3ViIGRlbGV0ZV8zCmludGNfMCAvLyAxCnJldHVybgptYWluX2wzMDoKdHhuIE9uQ29tcGxldGlvbgpwdXNoaW50IDQgLy8gVXBkYXRlQXBwbGljYXRpb24KPT0KdHhuIEFwcGxpY2F0aW9uSUQKaW50Y18xIC8vIDAKIT0KJiYKYXNzZXJ0CnR4bmEgQXBwbGljYXRpb25BcmdzIDEKY2FsbHN1YiB1cGRhdGVhcmdzXzIKaW50Y18wIC8vIDEKcmV0dXJuCm1haW5fbDMxOgp0eG4gT25Db21wbGV0aW9uCnB1c2hpbnQgNCAvLyBVcGRhdGVBcHBsaWNhdGlvbgo9PQp0eG4gQXBwbGljYXRpb25JRAppbnRjXzEgLy8gMAohPQomJgphc3NlcnQKY2FsbHN1YiB1cGRhdGVfMAppbnRjXzAgLy8gMQpyZXR1cm4KbWFpbl9sMzI6CnR4biBPbkNvbXBsZXRpb24KaW50Y18xIC8vIE5vT3AKPT0KYm56IG1haW5fbDQyCnR4biBPbkNvbXBsZXRpb24KaW50Y18wIC8vIE9wdEluCj09CmJueiBtYWluX2w0MQp0eG4gT25Db21wbGV0aW9uCmludGNfMiAvLyBDbG9zZU91dAo9PQpibnogbWFpbl9sNDAKdHhuIE9uQ29tcGxldGlvbgpwdXNoaW50IDQgLy8gVXBkYXRlQXBwbGljYXRpb24KPT0KYm56IG1haW5fbDM5CnR4biBPbkNvbXBsZXRpb24KaW50Y18zIC8vIERlbGV0ZUFwcGxpY2F0aW9uCj09CmJueiBtYWluX2wzOAplcnIKbWFpbl9sMzg6CnR4biBBcHBsaWNhdGlvbklECmludGNfMSAvLyAwCiE9CmFzc2VydApjYWxsc3ViIGRlbGV0ZWJhcmVfNAppbnRjXzAgLy8gMQpyZXR1cm4KbWFpbl9sMzk6CnR4biBBcHBsaWNhdGlvbklECmludGNfMSAvLyAwCiE9CmFzc2VydApjYWxsc3ViIHVwZGF0ZWJhcmVfMQppbnRjXzAgLy8gMQpyZXR1cm4KbWFpbl9sNDA6CnR4biBBcHBsaWNhdGlvbklECmludGNfMSAvLyAwCiE9CmFzc2VydApjYWxsc3ViIGNsb3Nlb3V0YmFyZV8xOAppbnRjXzAgLy8gMQpyZXR1cm4KbWFpbl9sNDE6CnR4biBBcHBsaWNhdGlvbklECmludGNfMSAvLyAwCiE9CmFzc2VydApjYWxsc3ViIG9wdGluYmFyZV8xNQppbnRjXzAgLy8gMQpyZXR1cm4KbWFpbl9sNDI6CnR4biBBcHBsaWNhdGlvbklECmludGNfMSAvLyAwCj09CmFzc2VydApjYWxsc3ViIGNyZWF0ZWJhcmVfOAppbnRjXzAgLy8gMQpyZXR1cm4KCi8vIHVwZGF0ZQp1cGRhdGVfMDoKcHJvdG8gMCAwCnR4biBTZW5kZXIKZ2xvYmFsIENyZWF0b3JBZGRyZXNzCj09Ci8vIHVuYXV0aG9yaXplZAphc3NlcnQKaW50YyA0IC8vIFRNUExfVVBEQVRBQkxFCi8vIGlzIHVwZGF0YWJsZQphc3NlcnQKYnl0ZWNfMCAvLyAiZ3JlZXRpbmciCnB1c2hieXRlcyAweDU1NzA2NDYxNzQ2NTY0MjA0MTQyNDkgLy8gIlVwZGF0ZWQgQUJJIgphcHBfZ2xvYmFsX3B1dApyZXRzdWIKCi8vIHVwZGF0ZV9iYXJlCnVwZGF0ZWJhcmVfMToKcHJvdG8gMCAwCnR4biBTZW5kZXIKZ2xvYmFsIENyZWF0b3JBZGRyZXNzCj09Ci8vIHVuYXV0aG9yaXplZAphc3NlcnQKaW50YyA0IC8vIFRNUExfVVBEQVRBQkxFCi8vIGlzIHVwZGF0YWJsZQphc3NlcnQKYnl0ZWNfMCAvLyAiZ3JlZXRpbmciCnB1c2hieXRlcyAweDU1NzA2NDYxNzQ2NTY0MjA0MjYxNzI2NSAvLyAiVXBkYXRlZCBCYXJlIgphcHBfZ2xvYmFsX3B1dApyZXRzdWIKCi8vIHVwZGF0ZV9hcmdzCnVwZGF0ZWFyZ3NfMjoKcHJvdG8gMSAwCnR4biBTZW5kZXIKZ2xvYmFsIENyZWF0b3JBZGRyZXNzCj09Ci8vIHVuYXV0aG9yaXplZAphc3NlcnQKZnJhbWVfZGlnIC0xCmV4dHJhY3QgMiAwCmJ5dGVjXzIgLy8gIlllcyIKPT0KLy8gcGFzc2VzIHVwZGF0ZSBjaGVjawphc3NlcnQKaW50YyA0IC8vIFRNUExfVVBEQVRBQkxFCi8vIGlzIHVwZGF0YWJsZQphc3NlcnQKYnl0ZWNfMCAvLyAiZ3JlZXRpbmciCnB1c2hieXRlcyAweDU1NzA2NDYxNzQ2NTY0MjA0MTcyNjc3MyAvLyAiVXBkYXRlZCBBcmdzIgphcHBfZ2xvYmFsX3B1dApyZXRzdWIKCi8vIGRlbGV0ZQpkZWxldGVfMzoKcHJvdG8gMCAwCnR4biBTZW5kZXIKZ2xvYmFsIENyZWF0b3JBZGRyZXNzCj09Ci8vIHVuYXV0aG9yaXplZAphc3NlcnQKaW50YyA1IC8vIFRNUExfREVMRVRBQkxFCi8vIGlzIGRlbGV0YWJsZQphc3NlcnQKcmV0c3ViCgovLyBkZWxldGVfYmFyZQpkZWxldGViYXJlXzQ6CnByb3RvIDAgMAp0eG4gU2VuZGVyCmdsb2JhbCBDcmVhdG9yQWRkcmVzcwo9PQovLyB1bmF1dGhvcml6ZWQKYXNzZXJ0CmludGMgNSAvLyBUTVBMX0RFTEVUQUJMRQovLyBpcyBkZWxldGFibGUKYXNzZXJ0CnJldHN1YgoKLy8gZGVsZXRlX2FyZ3MKZGVsZXRlYXJnc181Ogpwcm90byAxIDAKdHhuIFNlbmRlcgpnbG9iYWwgQ3JlYXRvckFkZHJlc3MKPT0KLy8gdW5hdXRob3JpemVkCmFzc2VydApmcmFtZV9kaWcgLTEKZXh0cmFjdCAyIDAKYnl0ZWNfMiAvLyAiWWVzIgo9PQovLyBwYXNzZXMgZGVsZXRlIGNoZWNrCmFzc2VydAppbnRjIDUgLy8gVE1QTF9ERUxFVEFCTEUKLy8gaXMgZGVsZXRhYmxlCmFzc2VydApyZXRzdWIKCi8vIGNyZWF0ZV9vcHRfaW4KY3JlYXRlb3B0aW5fNjoKcHJvdG8gMCAwCmJ5dGVjXzAgLy8gImdyZWV0aW5nIgpwdXNoYnl0ZXMgMHg0ZjcwNzQyMDQ5NmUgLy8gIk9wdCBJbiIKYXBwX2dsb2JhbF9wdXQKaW50Y18wIC8vIDEKcmV0dXJuCgovLyB1cGRhdGVfZ3JlZXRpbmcKdXBkYXRlZ3JlZXRpbmdfNzoKcHJvdG8gMSAwCmJ5dGVjXzAgLy8gImdyZWV0aW5nIgpmcmFtZV9kaWcgLTEKZXh0cmFjdCAyIDAKYXBwX2dsb2JhbF9wdXQKcmV0c3ViCgovLyBjcmVhdGVfYmFyZQpjcmVhdGViYXJlXzg6CnByb3RvIDAgMApieXRlY18wIC8vICJncmVldGluZyIKcHVzaGJ5dGVzIDB4NDg2NTZjNmM2ZjIwNDI2MTcyNjUgLy8gIkhlbGxvIEJhcmUiCmFwcF9nbG9iYWxfcHV0CmludGNfMCAvLyAxCnJldHVybgoKLy8gY3JlYXRlCmNyZWF0ZV85Ogpwcm90byAwIDAKYnl0ZWNfMCAvLyAiZ3JlZXRpbmciCnB1c2hieXRlcyAweDQ4NjU2YzZjNmYyMDQxNDI0OSAvLyAiSGVsbG8gQUJJIgphcHBfZ2xvYmFsX3B1dAppbnRjXzAgLy8gMQpyZXR1cm4KCi8vIGNyZWF0ZV9hcmdzCmNyZWF0ZWFyZ3NfMTA6CnByb3RvIDEgMApieXRlY18wIC8vICJncmVldGluZyIKZnJhbWVfZGlnIC0xCmV4dHJhY3QgMiAwCmFwcF9nbG9iYWxfcHV0CmludGNfMCAvLyAxCnJldHVybgoKLy8gaGVsbG8KaGVsbG9fMTE6CnByb3RvIDEgMQpieXRlYyA0IC8vICIiCmJ5dGVjXzAgLy8gImdyZWV0aW5nIgphcHBfZ2xvYmFsX2dldApieXRlYyA1IC8vICIsICIKY29uY2F0CmZyYW1lX2RpZyAtMQpleHRyYWN0IDIgMApjb25jYXQKZnJhbWVfYnVyeSAwCmZyYW1lX2RpZyAwCmxlbgppdG9iCmV4dHJhY3QgNiAwCmZyYW1lX2RpZyAwCmNvbmNhdApmcmFtZV9idXJ5IDAKcmV0c3ViCgovLyBoZWxsb19yZW1lbWJlcgpoZWxsb3JlbWVtYmVyXzEyOgpwcm90byAxIDEKYnl0ZWMgNCAvLyAiIgp0eG4gU2VuZGVyCmJ5dGVjXzEgLy8gImxhc3QiCmZyYW1lX2RpZyAtMQpleHRyYWN0IDIgMAphcHBfbG9jYWxfcHV0CmJ5dGVjXzAgLy8gImdyZWV0aW5nIgphcHBfZ2xvYmFsX2dldApieXRlYyA1IC8vICIsICIKY29uY2F0CmZyYW1lX2RpZyAtMQpleHRyYWN0IDIgMApjb25jYXQKZnJhbWVfYnVyeSAwCmZyYW1lX2RpZyAwCmxlbgppdG9iCmV4dHJhY3QgNiAwCmZyYW1lX2RpZyAwCmNvbmNhdApmcmFtZV9idXJ5IDAKcmV0c3ViCgovLyBnZXRfbGFzdApnZXRsYXN0XzEzOgpwcm90byAwIDEKYnl0ZWMgNCAvLyAiIgp0eG4gU2VuZGVyCmJ5dGVjXzEgLy8gImxhc3QiCmFwcF9sb2NhbF9nZXQKZnJhbWVfYnVyeSAwCmZyYW1lX2RpZyAwCmxlbgppdG9iCmV4dHJhY3QgNiAwCmZyYW1lX2RpZyAwCmNvbmNhdApmcmFtZV9idXJ5IDAKcmV0c3ViCgovLyBvcHRfaW4Kb3B0aW5fMTQ6CnByb3RvIDAgMAp0eG4gU2VuZGVyCmJ5dGVjXzEgLy8gImxhc3QiCnB1c2hieXRlcyAweDRmNzA3NDIwNDk2ZTIwNDE0MjQ5IC8vICJPcHQgSW4gQUJJIgphcHBfbG9jYWxfcHV0CmludGNfMCAvLyAxCnJldHVybgoKLy8gb3B0X2luX2JhcmUKb3B0aW5iYXJlXzE1Ogpwcm90byAwIDAKdHhuIFNlbmRlcgpieXRlY18xIC8vICJsYXN0IgpwdXNoYnl0ZXMgMHg0ZjcwNzQyMDQ5NmUyMDQyNjE3MjY1IC8vICJPcHQgSW4gQmFyZSIKYXBwX2xvY2FsX3B1dAppbnRjXzAgLy8gMQpyZXR1cm4KCi8vIG9wdF9pbl9hcmdzCm9wdGluYXJnc18xNjoKcHJvdG8gMSAwCmZyYW1lX2RpZyAtMQpleHRyYWN0IDIgMApieXRlY18yIC8vICJZZXMiCj09Ci8vIHBhc3NlcyBvcHRfaW4gY2hlY2sKYXNzZXJ0CnR4biBTZW5kZXIKYnl0ZWNfMSAvLyAibGFzdCIKcHVzaGJ5dGVzIDB4NGY3MDc0MjA0OTZlMjA0MTcyNjc3MyAvLyAiT3B0IEluIEFyZ3MiCmFwcF9sb2NhbF9wdXQKaW50Y18wIC8vIDEKcmV0dXJuCgovLyBjbG9zZV9vdXQKY2xvc2VvdXRfMTc6CnByb3RvIDAgMAppbnRjXzAgLy8gMQpyZXR1cm4KCi8vIGNsb3NlX291dF9iYXJlCmNsb3Nlb3V0YmFyZV8xODoKcHJvdG8gMCAwCmludGNfMCAvLyAxCnJldHVybgoKLy8gY2xvc2Vfb3V0X2FyZ3MKY2xvc2VvdXRhcmdzXzE5Ogpwcm90byAxIDAKZnJhbWVfZGlnIC0xCmV4dHJhY3QgMiAwCmJ5dGVjXzIgLy8gIlllcyIKPT0KLy8gcGFzc2VzIGNsb3NlX291dCBjaGVjawphc3NlcnQKaW50Y18wIC8vIDEKcmV0dXJu", + "clear": "I3ByYWdtYSB2ZXJzaW9uIDgKaW50Y2Jsb2NrIDEKY2FsbHN1YiBjbGVhcnN0YXRlXzAKaW50Y18wIC8vIDEKcmV0dXJuCgovLyBjbGVhcl9zdGF0ZQpjbGVhcnN0YXRlXzA6CnByb3RvIDAgMAppbnRjXzAgLy8gMQpyZXR1cm4=" }, "state": { "global": { @@ -48,7 +88,7 @@ "num_uints": 0 }, "local": { - "num_byte_slices": 0, + "num_byte_slices": 1, "num_uints": 0 } }, @@ -64,7 +104,13 @@ "reserved": {} }, "local": { - "declared": {}, + "declared": { + "last": { + "type": "bytes", + "key": "last", + "descr": "" + } + }, "reserved": {} } }, @@ -78,6 +124,18 @@ "type": "void" } }, + { + "name": "update_args", + "args": [ + { + "type": "string", + "name": "check" + } + ], + "returns": { + "type": "void" + } + }, { "name": "delete", "args": [], @@ -85,6 +143,18 @@ "type": "void" } }, + { + "name": "delete_args", + "args": [ + { + "type": "string", + "name": "check" + } + ], + "returns": { + "type": "void" + } + }, { "name": "create_opt_in", "args": [], @@ -134,11 +204,72 @@ "returns": { "type": "string" } + }, + { + "name": "hello_remember", + "args": [ + { + "type": "string", + "name": "name" + } + ], + "returns": { + "type": "string" + } + }, + { + "name": "get_last", + "args": [], + "returns": { + "type": "string" + } + }, + { + "name": "opt_in", + "args": [], + "returns": { + "type": "void" + } + }, + { + "name": "opt_in_args", + "args": [ + { + "type": "string", + "name": "check" + } + ], + "returns": { + "type": "void" + } + }, + { + "name": "close_out", + "args": [], + "returns": { + "type": "void" + } + }, + { + "name": "close_out_args", + "args": [ + { + "type": "string", + "name": "check" + } + ], + "returns": { + "type": "void" + } } ], "networks": {} }, "bare_call_config": { - "no_op": "CREATE" + "no_op": "CREATE", + "opt_in": "CALL", + "close_out": "CALL", + "update_application": "CALL", + "delete_application": "CALL" } } \ No newline at end of file diff --git a/tests/app_resolve.json b/tests/app_resolve.json new file mode 100644 index 0000000..cccae64 --- /dev/null +++ b/tests/app_resolve.json @@ -0,0 +1,141 @@ +{ + "hints": { + "update()void": { + "call_config": { + "update_application": "CALL" + } + }, + "delete()void": { + "call_config": { + "delete_application": "CALL" + } + }, + "opt_in()void": { + "call_config": { + "opt_in": "CALL" + } + }, + "close_out()void": { + "call_config": { + "close_out": "CALL" + } + }, + "add(uint64,uint64)uint64": { + "call_config": { + "no_op": "CALL" + } + }, + "dummy()string": { + "read_only": true, + "call_config": { + "no_op": "CALL" + } + } + }, + "source": { + "approval": "I3ByYWdtYSB2ZXJzaW9uIDgKaW50Y2Jsb2NrIDAgMSAyCmJ5dGVjYmxvY2sgMHgxNTFmN2M3NQp0eG4gTnVtQXBwQXJncwppbnRjXzAgLy8gMAo9PQpibnogbWFpbl9sMTQKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMApwdXNoYnl0ZXMgMHhhMGU4MTg3MiAvLyAidXBkYXRlKCl2b2lkIgo9PQpibnogbWFpbl9sMTMKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMApwdXNoYnl0ZXMgMHgyNDM3OGQzYyAvLyAiZGVsZXRlKCl2b2lkIgo9PQpibnogbWFpbl9sMTIKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMApwdXNoYnl0ZXMgMHgzMGM2ZDU4YSAvLyAib3B0X2luKCl2b2lkIgo9PQpibnogbWFpbl9sMTEKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMApwdXNoYnl0ZXMgMHgxNjU4YWEyZiAvLyAiY2xvc2Vfb3V0KCl2b2lkIgo9PQpibnogbWFpbl9sMTAKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMApwdXNoYnl0ZXMgMHhmZTZiZGY2OSAvLyAiYWRkKHVpbnQ2NCx1aW50NjQpdWludDY0Igo9PQpibnogbWFpbl9sOQp0eG5hIEFwcGxpY2F0aW9uQXJncyAwCnB1c2hieXRlcyAweDU0YTlkMTdiIC8vICJkdW1teSgpc3RyaW5nIgo9PQpibnogbWFpbl9sOAplcnIKbWFpbl9sODoKdHhuIE9uQ29tcGxldGlvbgppbnRjXzAgLy8gTm9PcAo9PQp0eG4gQXBwbGljYXRpb25JRAppbnRjXzAgLy8gMAohPQomJgphc3NlcnQKY2FsbHN1YiBkdW1teV82CnN0b3JlIDMKYnl0ZWNfMCAvLyAweDE1MWY3Yzc1CmxvYWQgMwpjb25jYXQKbG9nCmludGNfMSAvLyAxCnJldHVybgptYWluX2w5Ogp0eG4gT25Db21wbGV0aW9uCmludGNfMCAvLyBOb09wCj09CnR4biBBcHBsaWNhdGlvbklECmludGNfMCAvLyAwCiE9CiYmCmFzc2VydAp0eG5hIEFwcGxpY2F0aW9uQXJncyAxCmJ0b2kKc3RvcmUgMAp0eG5hIEFwcGxpY2F0aW9uQXJncyAyCmJ0b2kKc3RvcmUgMQpsb2FkIDAKbG9hZCAxCmNhbGxzdWIgYWRkXzUKc3RvcmUgMgpieXRlY18wIC8vIDB4MTUxZjdjNzUKbG9hZCAyCml0b2IKY29uY2F0CmxvZwppbnRjXzEgLy8gMQpyZXR1cm4KbWFpbl9sMTA6CnR4biBPbkNvbXBsZXRpb24KaW50Y18yIC8vIENsb3NlT3V0Cj09CnR4biBBcHBsaWNhdGlvbklECmludGNfMCAvLyAwCiE9CiYmCmFzc2VydApjYWxsc3ViIGNsb3Nlb3V0XzQKaW50Y18xIC8vIDEKcmV0dXJuCm1haW5fbDExOgp0eG4gT25Db21wbGV0aW9uCmludGNfMSAvLyBPcHRJbgo9PQp0eG4gQXBwbGljYXRpb25JRAppbnRjXzAgLy8gMAohPQomJgphc3NlcnQKY2FsbHN1YiBvcHRpbl8zCmludGNfMSAvLyAxCnJldHVybgptYWluX2wxMjoKdHhuIE9uQ29tcGxldGlvbgpwdXNoaW50IDUgLy8gRGVsZXRlQXBwbGljYXRpb24KPT0KdHhuIEFwcGxpY2F0aW9uSUQKaW50Y18wIC8vIDAKIT0KJiYKYXNzZXJ0CmNhbGxzdWIgZGVsZXRlXzIKaW50Y18xIC8vIDEKcmV0dXJuCm1haW5fbDEzOgp0eG4gT25Db21wbGV0aW9uCnB1c2hpbnQgNCAvLyBVcGRhdGVBcHBsaWNhdGlvbgo9PQp0eG4gQXBwbGljYXRpb25JRAppbnRjXzAgLy8gMAohPQomJgphc3NlcnQKY2FsbHN1YiB1cGRhdGVfMQppbnRjXzEgLy8gMQpyZXR1cm4KbWFpbl9sMTQ6CnR4biBPbkNvbXBsZXRpb24KaW50Y18wIC8vIE5vT3AKPT0KYm56IG1haW5fbDE2CmVycgptYWluX2wxNjoKdHhuIEFwcGxpY2F0aW9uSUQKaW50Y18wIC8vIDAKPT0KYXNzZXJ0CmNhbGxzdWIgY3JlYXRlXzAKaW50Y18xIC8vIDEKcmV0dXJuCgovLyBjcmVhdGUKY3JlYXRlXzA6CnByb3RvIDAgMApwdXNoYnl0ZXMgMHg2NzZjNmY2MjYxNmM1ZjczNzQ2MTc0NjU1Zjc2NjE2YzVmNjI3OTc0NjUgLy8gImdsb2JhbF9zdGF0ZV92YWxfYnl0ZSIKcHVzaGJ5dGVzIDB4NzQ2NTczNzQgLy8gInRlc3QiCmFwcF9nbG9iYWxfcHV0CnB1c2hieXRlcyAweDY3NmM2ZjYyNjE2YzVmNzM3NDYxNzQ2NTVmNzY2MTZjNWY2OTZlNzQgLy8gImdsb2JhbF9zdGF0ZV92YWxfaW50IgppbnRjXzEgLy8gMQphcHBfZ2xvYmFsX3B1dAp0eG4gTm90ZQpsZW4KaW50Y18wIC8vIDAKPT0KYXNzZXJ0CmludGNfMSAvLyAxCnJldHVybgoKLy8gdXBkYXRlCnVwZGF0ZV8xOgpwcm90byAwIDAKdHhuIFNlbmRlcgpnbG9iYWwgQ3JlYXRvckFkZHJlc3MKPT0KLy8gdW5hdXRob3JpemVkCmFzc2VydAppbnRjXzEgLy8gMQpyZXR1cm4KCi8vIGRlbGV0ZQpkZWxldGVfMjoKcHJvdG8gMCAwCnR4biBTZW5kZXIKZ2xvYmFsIENyZWF0b3JBZGRyZXNzCj09Ci8vIHVuYXV0aG9yaXplZAphc3NlcnQKaW50Y18xIC8vIDEKcmV0dXJuCgovLyBvcHRfaW4Kb3B0aW5fMzoKcHJvdG8gMCAwCnR4biBTZW5kZXIKcHVzaGJ5dGVzIDB4NjE2MzYzNzQ1ZjczNzQ2MTc0NjU1Zjc2NjE2YzVmNjI3OTc0NjUgLy8gImFjY3Rfc3RhdGVfdmFsX2J5dGUiCnB1c2hieXRlcyAweDZjNmY2MzYxNmMyZDc0NjU3Mzc0IC8vICJsb2NhbC10ZXN0IgphcHBfbG9jYWxfcHV0CnR4biBTZW5kZXIKcHVzaGJ5dGVzIDB4NjE2MzYzNzQ1ZjczNzQ2MTc0NjU1Zjc2NjE2YzVmNjk2ZTc0IC8vICJhY2N0X3N0YXRlX3ZhbF9pbnQiCmludGNfMiAvLyAyCmFwcF9sb2NhbF9wdXQKdHhuIE5vdGUKbGVuCmludGNfMCAvLyAwCj09CmFzc2VydAppbnRjXzEgLy8gMQpyZXR1cm4KCi8vIGNsb3NlX291dApjbG9zZW91dF80Ogpwcm90byAwIDAKdHhuIE5vdGUKbGVuCmludGNfMCAvLyAwCj09CmFzc2VydAppbnRjXzEgLy8gMQpyZXR1cm4KCi8vIGFkZAphZGRfNToKcHJvdG8gMiAxCmludGNfMCAvLyAwCmZyYW1lX2RpZyAtMgpmcmFtZV9kaWcgLTEKKwpmcmFtZV9idXJ5IDAKcmV0c3ViCgovLyBkdW1teQpkdW1teV82Ogpwcm90byAwIDEKcHVzaGJ5dGVzIDB4IC8vICIiCnB1c2hieXRlcyAweDAwMDg2NDY1NjE2NDYyNjU2NTY2IC8vIDB4MDAwODY0NjU2MTY0NjI2NTY1NjYKZnJhbWVfYnVyeSAwCnJldHN1Yg==", + "clear": "I3ByYWdtYSB2ZXJzaW9uIDgKaW50Y2Jsb2NrIDEKY2FsbHN1YiBjbGVhcnN0YXRlXzAKaW50Y18wIC8vIDEKcmV0dXJuCgovLyBjbGVhcl9zdGF0ZQpjbGVhcnN0YXRlXzA6CnByb3RvIDAgMAp0eG4gTm90ZQpsZW4KcHVzaGludCAwIC8vIDAKPT0KYXNzZXJ0CmludGNfMCAvLyAxCnJldHVybg==" + }, + "state": { + "global": { + "num_byte_slices": 1, + "num_uints": 1 + }, + "local": { + "num_byte_slices": 1, + "num_uints": 1 + } + }, + "schema": { + "global": { + "declared": { + "global_state_val_byte": { + "type": "bytes", + "key": "global_state_val_byte", + "descr": "" + }, + "global_state_val_int": { + "type": "uint64", + "key": "global_state_val_int", + "descr": "" + } + }, + "reserved": {} + }, + "local": { + "declared": { + "acct_state_val_byte": { + "type": "bytes", + "key": "acct_state_val_byte", + "descr": "" + }, + "acct_state_val_int": { + "type": "uint64", + "key": "acct_state_val_int", + "descr": "" + } + }, + "reserved": {} + } + }, + "contract": { + "name": "App", + "methods": [ + { + "name": "update", + "args": [], + "returns": { + "type": "void" + } + }, + { + "name": "delete", + "args": [], + "returns": { + "type": "void" + } + }, + { + "name": "opt_in", + "args": [], + "returns": { + "type": "void" + } + }, + { + "name": "close_out", + "args": [], + "returns": { + "type": "void" + } + }, + { + "name": "add", + "args": [ + { + "type": "uint64", + "name": "a" + }, + { + "type": "uint64", + "name": "b" + } + ], + "returns": { + "type": "uint64" + } + }, + { + "name": "dummy", + "args": [], + "returns": { + "type": "string" + } + } + ], + "networks": {} + }, + "bare_call_config": { + "no_op": "CREATE" + } +} \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index d0674c8..14b29e6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,8 +13,10 @@ get_account, get_algod_client, get_indexer_client, + get_kmd_client_from_algod_client, replace_template_variables, ) +from algosdk.kmd import KMDClient from algosdk.v2client.algod import AlgodClient from algosdk.v2client.indexer import IndexerClient from dotenv import load_dotenv @@ -65,7 +67,11 @@ def check_output_stability(logs: str, *, test_name: str | None = None) -> None: def read_spec( - file_name: str, *, updatable: bool | None = None, deletable: bool | None = None + file_name: str, + *, + updatable: bool | None = None, + deletable: bool | None = None, + name: str | None = None, ) -> ApplicationSpecification: path = Path(__file__).parent / file_name spec = ApplicationSpecification.from_json(Path(path).read_text(encoding="utf-8")) @@ -82,16 +88,20 @@ def read_spec( .replace(f"// {UPDATABLE_TEMPLATE_NAME}", "// updatable") .replace(f"// {DELETABLE_TEMPLATE_NAME}", "// deletable") ) + if name is not None: + spec.contract.name = name return spec def get_specs( - updatable: bool | None = None, deletable: bool | None = None + updatable: bool | None = None, + deletable: bool | None = None, + name: str | None = None, ) -> tuple[ApplicationSpecification, ApplicationSpecification, ApplicationSpecification]: specs = ( - read_spec("app_v1.json", updatable=updatable, deletable=deletable), - read_spec("app_v2.json", updatable=updatable, deletable=deletable), - read_spec("app_v3.json", updatable=updatable, deletable=deletable), + read_spec("app_v1.json", updatable=updatable, deletable=deletable, name=name), + read_spec("app_v2.json", updatable=updatable, deletable=deletable, name=name), + read_spec("app_v3.json", updatable=updatable, deletable=deletable, name=name), ) return specs @@ -102,12 +112,25 @@ def get_unique_name() -> str: return name -@pytest.fixture() +def is_opted_in(client_fixture: ApplicationClient) -> bool: + assert client_fixture.sender + account_info = client_fixture.algod_client.account_info(client_fixture.sender) + assert isinstance(account_info, dict) + apps_local_state = account_info["apps-local-state"] + return any(x for x in apps_local_state if x["id"] == client_fixture.app_id) + + +@pytest.fixture(scope="session") def algod_client() -> AlgodClient: return get_algod_client() -@pytest.fixture() +@pytest.fixture(scope="session") +def kmd_client(algod_client: AlgodClient) -> KMDClient: + return get_kmd_client_from_algod_client(algod_client) + + +@pytest.fixture(scope="session") def indexer_client() -> IndexerClient: return get_indexer_client() @@ -119,15 +142,14 @@ def creator(algod_client: AlgodClient) -> Account: return creator -@pytest.fixture() +@pytest.fixture(scope="session") +def funded_account(algod_client: AlgodClient) -> Account: + creator_name = get_unique_name() + creator = get_account(algod_client, creator_name) + return creator + + +@pytest.fixture(scope="session") def app_spec() -> ApplicationSpecification: app_spec = read_spec("app_client_test.json", deletable=True, updatable=True) return app_spec - - -@pytest.fixture() -def client_fixture( - algod_client: AlgodClient, indexer_client: IndexerClient, creator: Account, app_spec: ApplicationSpecification -) -> ApplicationClient: - client = ApplicationClient(algod_client, app_spec, creator=creator, indexer_client=indexer_client) - return client diff --git a/tests/test_account.py b/tests/test_account.py index 8e4e66a..d0dedc6 100644 --- a/tests/test_account.py +++ b/tests/test_account.py @@ -1,6 +1,7 @@ from algokit_utils import get_account from algosdk.v2client.algod import AlgodClient -from conftest import get_unique_name + +from tests.conftest import get_unique_name def test_account_can_be_called_twice(algod_client: AlgodClient) -> None: diff --git a/tests/test_app_client.py b/tests/test_app_client.py index 50246d3..8782617 100644 --- a/tests/test_app_client.py +++ b/tests/test_app_client.py @@ -1,112 +1,29 @@ +import pytest from algokit_utils import ( - ABICallArgs, - Account, - ApplicationClient, - ApplicationSpecification, - TransferParameters, - get_app_id_from_tx_id, - transfer, + DeploymentFailedError, + get_next_version, ) -from algosdk.atomic_transaction_composer import AtomicTransactionComposer -from algosdk.transaction import OnComplete -def test_bare_create(client_fixture: ApplicationClient) -> None: - client_fixture.create(abi_method=False) - - assert client_fixture.call("hello", args={"name": "test"}).abi_result.return_value == "Hello Bare, test" - - -def test_abi_create(client_fixture: ApplicationClient) -> None: - client_fixture.create("create") - - assert client_fixture.call("hello", args={"name": "test"}).abi_result.return_value == "Hello ABI, test" - - -def test_abi_create_args(client_fixture: ApplicationClient, app_spec: ApplicationSpecification) -> None: - create = next(m for m in app_spec.contract.methods if m.name == "create_args") - client_fixture.create(create, args={"greeting": "ahoy"}) - - assert client_fixture.call("hello", args={"name": "test"}).abi_result.return_value == "ahoy, test" - - -def test_create_auto_find(client_fixture: ApplicationClient) -> None: - client_fixture.create(on_complete=OnComplete.OptInOC) - - assert client_fixture.call("hello", args={"name": "test"}).abi_result.return_value == "Opt In, test" - - -def test_abi_create_with_atc(client_fixture: ApplicationClient) -> None: - atc = AtomicTransactionComposer() - client_fixture.compose_create(atc, "create") - - create_result = atc.execute(client_fixture.algod_client, 4) - client_fixture.app_id = get_app_id_from_tx_id(client_fixture.algod_client, create_result.tx_ids[0]) - - assert client_fixture.call("hello", args={"name": "test"}).abi_result.return_value == "Hello ABI, test" - - -def test_bare_create_with_atc(client_fixture: ApplicationClient) -> None: - atc = AtomicTransactionComposer() - client_fixture.compose_create(atc, abi_method=False) - - create_result = atc.execute(client_fixture.algod_client, 4) - client_fixture.app_id = get_app_id_from_tx_id(client_fixture.algod_client, create_result.tx_ids[0]) - - assert client_fixture.call("hello", args={"name": "test"}).abi_result.return_value == "Hello Bare, test" - - -def test_abi_call_with_atc(client_fixture: ApplicationClient) -> None: - client_fixture.create("create") - atc = AtomicTransactionComposer() - client_fixture.compose_call(atc, "hello", args={"name": "test"}) - result = atc.execute(client_fixture.algod_client, 4) - - assert result.abi_results[0].return_value == "Hello ABI, test" - - -def test_abi_call_multiple_times_with_atc(client_fixture: ApplicationClient) -> None: - client_fixture.create("create") - atc = AtomicTransactionComposer() - client_fixture.compose_call(atc, "hello", args={"name": "test"}) - client_fixture.compose_call(atc, "hello", args={"name": "test2"}) - client_fixture.compose_call(atc, "hello", args={"name": "test3"}) - result = atc.execute(client_fixture.algod_client, 4) - - assert result.abi_results[0].return_value == "Hello ABI, test" - assert result.abi_results[1].return_value == "Hello ABI, test2" - assert result.abi_results[2].return_value == "Hello ABI, test3" - - -def test_deploy_with_create(client_fixture: ApplicationClient, creator: Account) -> None: - client_fixture.deploy( - "v1", - create_args=ABICallArgs( - method="create", - ), - ) - - transfer( - TransferParameters(from_account=creator, to_address=client_fixture.app_address, amount=100_000), - client_fixture.algod_client, - ) - - assert client_fixture.call("hello", args={"name": "test"}).abi_result.return_value == "Hello ABI, test" - - -def test_deploy_with_create_args(client_fixture: ApplicationClient, app_spec: ApplicationSpecification) -> None: - create_args = next(m for m in app_spec.contract.methods if m.name == "create_args") - client_fixture.deploy("v1", create_args=ABICallArgs(method=create_args, args={"greeting": "deployed"})) - - assert client_fixture.call("hello", args={"name": "test"}).abi_result.return_value == "deployed, test" - +@pytest.mark.parametrize( + ("current", "expected_next"), + [ + ("1", "2"), + ("v1", "v2"), + ("v1-alpha", "v2-alpha"), + ("1.0", "1.1"), + ("v1.0", "v1.1"), + ("v1.0-alpha", "v1.1-alpha"), + ("1.0.0", "1.0.1"), + ("v1.0.0", "v1.0.1"), + ("v1.0.0-alpha", "v1.0.1-alpha"), + ], +) +def test_auto_version_increment(current: str, expected_next: str) -> None: + value = get_next_version(current) + assert value == expected_next -def test_deploy_with_bare_create(client_fixture: ApplicationClient) -> None: - client_fixture.deploy( - "v1", - create_args=ABICallArgs( - method=False, - ), - ) - assert client_fixture.call("hello", args={"name": "test"}).abi_result.return_value == "Hello Bare, test" +def test_auto_version_increment_failure() -> None: + with pytest.raises(DeploymentFailedError): + get_next_version("teapot") diff --git a/tests/test_app_client_call.py b/tests/test_app_client_call.py new file mode 100644 index 0000000..b67b269 --- /dev/null +++ b/tests/test_app_client_call.py @@ -0,0 +1,57 @@ +import pytest +from algokit_utils import ( + ApplicationClient, + ApplicationSpecification, + CreateCallParameters, + get_account, +) +from algosdk.atomic_transaction_composer import AtomicTransactionComposer +from algosdk.transaction import ApplicationCallTxn +from algosdk.v2client.algod import AlgodClient + +from tests.conftest import get_unique_name + + +@pytest.fixture(scope="module") +def client_fixture(algod_client: AlgodClient, app_spec: ApplicationSpecification) -> ApplicationClient: + creator_name = get_unique_name() + creator = get_account(algod_client, creator_name) + client = ApplicationClient(algod_client, app_spec, signer=creator) + create_response = client.create("create") + assert create_response.tx_id + return client + + +def test_abi_call_with_atc(client_fixture: ApplicationClient) -> None: + atc = AtomicTransactionComposer() + client_fixture.compose_call(atc, "hello", name="test") + result = atc.execute(client_fixture.algod_client, 4) + + assert result.abi_results[0].return_value == "Hello ABI, test" + + +def test_abi_call_multiple_times_with_atc(client_fixture: ApplicationClient) -> None: + atc = AtomicTransactionComposer() + client_fixture.compose_call(atc, "hello", name="test") + client_fixture.compose_call(atc, "hello", name="test2") + client_fixture.compose_call(atc, "hello", name="test3") + result = atc.execute(client_fixture.algod_client, 4) + + assert result.abi_results[0].return_value == "Hello ABI, test" + assert result.abi_results[1].return_value == "Hello ABI, test2" + assert result.abi_results[2].return_value == "Hello ABI, test3" + + +def test_call_parameters_from_derived_type_ignored(client_fixture: ApplicationClient) -> None: + parameters = CreateCallParameters( + extra_pages=1, + ) + + client_fixture.app_id = 123 + atc = AtomicTransactionComposer() + client_fixture.compose_call(atc, "hello", transaction_parameters=parameters, name="test") + + signed_txn = atc.txn_list[0] + app_txn = signed_txn.txn + assert isinstance(app_txn, ApplicationCallTxn) + assert app_txn.extra_pages == 0 diff --git a/tests/test_app_client_clear_state.py b/tests/test_app_client_clear_state.py new file mode 100644 index 0000000..db5bd6e --- /dev/null +++ b/tests/test_app_client_clear_state.py @@ -0,0 +1,60 @@ +import base64 + +import pytest +from algokit_utils import ( + Account, + ApplicationClient, + ApplicationSpecification, +) +from algosdk.v2client.algod import AlgodClient +from algosdk.v2client.indexer import IndexerClient + +from tests.conftest import is_opted_in + + +@pytest.fixture +def client_fixture( + algod_client: AlgodClient, + indexer_client: IndexerClient, + app_spec: ApplicationSpecification, + funded_account: Account, +) -> ApplicationClient: + client = ApplicationClient(algod_client, app_spec, creator=funded_account, indexer_client=indexer_client) + create_response = client.create("create") + assert create_response.tx_id + opt_in_response = client.opt_in("opt_in") + assert opt_in_response.tx_id + return client + + +def test_clear_state(client_fixture: ApplicationClient) -> None: + assert is_opted_in(client_fixture) + + close_out_response = client_fixture.clear_state() + assert close_out_response.tx_id + + assert not is_opted_in(client_fixture) + + +def test_clear_state_app_already_deleted(client_fixture: ApplicationClient) -> None: + assert is_opted_in(client_fixture) + + client_fixture.delete("delete") + assert is_opted_in(client_fixture) + + close_out_response = client_fixture.clear_state() + assert close_out_response.tx_id + + assert not is_opted_in(client_fixture) + + +def test_clear_state_app_args(client_fixture: ApplicationClient) -> None: + assert is_opted_in(client_fixture) + app_args = [b"test", b"data"] + + close_out_response = client_fixture.clear_state(app_args=app_args) + assert close_out_response.tx_id + + tx_info = client_fixture.algod_client.pending_transaction_info(close_out_response.tx_id) + assert isinstance(tx_info, dict) + assert [base64.b64decode(x) for x in tx_info["txn"]["txn"]["apaa"]] == app_args diff --git a/tests/test_app_client_close_out.approvals/test_abi_close_out_args_fails.approved.txt b/tests/test_app_client_close_out.approvals/test_abi_close_out_args_fails.approved.txt new file mode 100644 index 0000000..a71c629 --- /dev/null +++ b/tests/test_app_client_close_out.approvals/test_abi_close_out_args_fails.approved.txt @@ -0,0 +1,10 @@ +Txn {txn} had error 'assert failed pc=1004' at PC 1004 and Source Line 599: + + frame_dig -1 + extract 2 0 + bytec_2 // "Yes" + == + // passes close_out check + assert <-- Error + intc_0 // 1 + return \ No newline at end of file diff --git a/tests/test_app_client_close_out.py b/tests/test_app_client_close_out.py new file mode 100644 index 0000000..25fac27 --- /dev/null +++ b/tests/test_app_client_close_out.py @@ -0,0 +1,64 @@ +import pytest +from algokit_utils import ( + Account, + ApplicationClient, + ApplicationSpecification, + LogicError, +) +from algosdk.v2client.algod import AlgodClient +from algosdk.v2client.indexer import IndexerClient + +from tests.conftest import check_output_stability, is_opted_in + + +@pytest.fixture +def client_fixture( + algod_client: AlgodClient, + indexer_client: IndexerClient, + app_spec: ApplicationSpecification, + funded_account: Account, +) -> ApplicationClient: + client = ApplicationClient(algod_client, app_spec, creator=funded_account, indexer_client=indexer_client) + create_response = client.create("create") + assert create_response.tx_id + opt_in_response = client.opt_in("opt_in") + assert opt_in_response.tx_id + return client + + +def test_abi_close_out(client_fixture: ApplicationClient) -> None: + assert is_opted_in(client_fixture) + + close_out_response = client_fixture.close_out("close_out") + assert close_out_response.tx_id + + assert not is_opted_in(client_fixture) + + +def test_bare_close_out(client_fixture: ApplicationClient) -> None: + assert is_opted_in(client_fixture) + + close_out_response = client_fixture.close_out(call_abi_method=False) + assert close_out_response.tx_id + + assert not is_opted_in(client_fixture) + + +def test_abi_close_out_args(client_fixture: ApplicationClient) -> None: + assert is_opted_in(client_fixture) + + close_out_response = client_fixture.close_out("close_out_args", check="Yes") + assert close_out_response.tx_id + + assert not is_opted_in(client_fixture) + + +def test_abi_close_out_args_fails(client_fixture: ApplicationClient) -> None: + assert is_opted_in(client_fixture) + + with pytest.raises(LogicError) as ex: + client_fixture.close_out("close_out_args", check="No") + + check_output_stability(str(ex.value).replace(ex.value.transaction_id, "{txn}")) + + assert is_opted_in(client_fixture) diff --git a/tests/test_app_client_create.approvals/test_create_auto_find_ambiguous.approved.txt b/tests/test_app_client_create.approvals/test_create_auto_find_ambiguous.approved.txt new file mode 100644 index 0000000..ddc6238 --- /dev/null +++ b/tests/test_app_client_create.approvals/test_create_auto_find_ambiguous.approved.txt @@ -0,0 +1 @@ +Could not find an exact method to use for NoOpOC with call_config of CREATE, specify the exact method using abi_method and args parameters, considered: create()void, bare \ No newline at end of file diff --git a/tests/test_app_client_create.py b/tests/test_app_client_create.py new file mode 100644 index 0000000..4263cce --- /dev/null +++ b/tests/test_app_client_create.py @@ -0,0 +1,232 @@ +import pytest +from algokit_utils import ( + Account, + ApplicationClient, + ApplicationSpecification, + CreateCallParameters, + get_account, + get_app_id_from_tx_id, +) +from algosdk.atomic_transaction_composer import AccountTransactionSigner, AtomicTransactionComposer +from algosdk.transaction import ApplicationCallTxn, OnComplete +from algosdk.v2client.algod import AlgodClient +from algosdk.v2client.indexer import IndexerClient + +from tests.conftest import check_output_stability, get_unique_name + + +@pytest.fixture(scope="module") +def client_fixture( + algod_client: AlgodClient, + indexer_client: IndexerClient, + app_spec: ApplicationSpecification, + funded_account: Account, +) -> ApplicationClient: + client = ApplicationClient(algod_client, app_spec, creator=funded_account, indexer_client=indexer_client) + return client + + +def test_bare_create(client_fixture: ApplicationClient) -> None: + client_fixture.create(call_abi_method=False) + + assert client_fixture.call("hello", name="test").return_value == "Hello Bare, test" + + +def test_abi_create(client_fixture: ApplicationClient) -> None: + client_fixture.create("create") + + assert client_fixture.call("hello", name="test").return_value == "Hello ABI, test" + + +@pytest.mark.parametrize("method", ["create_args", "create_args(string)void", True]) +def test_abi_create_args( + method: str | bool, client_fixture: ApplicationClient, app_spec: ApplicationSpecification +) -> None: + client_fixture.create(method, greeting="ahoy") + + assert client_fixture.call("hello", name="test").return_value == "ahoy, test" + + +def test_create_auto_find(client_fixture: ApplicationClient) -> None: + client_fixture.create(transaction_parameters=CreateCallParameters(on_complete=OnComplete.OptInOC)) + + assert client_fixture.call("hello", name="test").return_value == "Opt In, test" + + +def test_create_auto_find_ambiguous(client_fixture: ApplicationClient) -> None: + with pytest.raises(Exception) as ex: + client_fixture.create() + check_output_stability(str(ex.value)) + + +def test_abi_create_with_atc(client_fixture: ApplicationClient) -> None: + atc = AtomicTransactionComposer() + client_fixture.compose_create(atc, "create") + + create_result = atc.execute(client_fixture.algod_client, 4) + client_fixture.app_id = get_app_id_from_tx_id(client_fixture.algod_client, create_result.tx_ids[0]) + + assert client_fixture.call("hello", name="test").return_value == "Hello ABI, test" + + +def test_bare_create_with_atc(client_fixture: ApplicationClient) -> None: + atc = AtomicTransactionComposer() + client_fixture.compose_create(atc, call_abi_method=False) + + create_result = atc.execute(client_fixture.algod_client, 4) + client_fixture.app_id = get_app_id_from_tx_id(client_fixture.algod_client, create_result.tx_ids[0]) + + assert client_fixture.call("hello", name="test").return_value == "Hello Bare, test" + + +def test_create_parameters_lease(client_fixture: ApplicationClient) -> None: + lease = b"a" * 32 + + atc = AtomicTransactionComposer() + client_fixture.compose_create( + atc, + "create", + transaction_parameters=CreateCallParameters( + lease=lease, + ), + ) + + signed_txn = atc.txn_list[0] + assert signed_txn.txn.lease == lease + + +def test_create_parameters_note(client_fixture: ApplicationClient) -> None: + note = b"test note" + + atc = AtomicTransactionComposer() + client_fixture.compose_create( + atc, + "create", + transaction_parameters=CreateCallParameters( + note=note, + ), + ) + + signed_txn = atc.txn_list[0] + assert signed_txn.txn.note == note + + +def test_create_parameters_on_complete(client_fixture: ApplicationClient) -> None: + on_complete = OnComplete.OptInOC + + atc = AtomicTransactionComposer() + client_fixture.compose_create( + atc, "create", transaction_parameters=CreateCallParameters(on_complete=OnComplete.OptInOC) + ) + + signed_txn = atc.txn_list[0] + app_txn = signed_txn.txn + assert isinstance(app_txn, ApplicationCallTxn) + assert app_txn.on_complete == on_complete + + +def test_create_parameters_extra_pages(client_fixture: ApplicationClient) -> None: + extra_pages = 1 + + atc = AtomicTransactionComposer() + client_fixture.compose_create(atc, "create", transaction_parameters=CreateCallParameters(extra_pages=extra_pages)) + + signed_txn = atc.txn_list[0] + app_txn = signed_txn.txn + assert isinstance(app_txn, ApplicationCallTxn) + assert app_txn.extra_pages == extra_pages + + +def test_create_parameters_signer(client_fixture: ApplicationClient) -> None: + another_account_name = get_unique_name() + account = get_account(client_fixture.algod_client, another_account_name) + signer = AccountTransactionSigner(account.private_key) + + atc = AtomicTransactionComposer() + client_fixture.compose_create(atc, "create", transaction_parameters=CreateCallParameters(signer=signer)) + + signed_txn = atc.txn_list[0] + assert isinstance(signed_txn.signer, AccountTransactionSigner) + assert signed_txn.signer.private_key == signer.private_key + + +def test_create_parameters_sender(client_fixture: ApplicationClient) -> None: + another_account_name = get_unique_name() + account = get_account(client_fixture.algod_client, another_account_name) + + atc = AtomicTransactionComposer() + client_fixture.compose_create(atc, "create", transaction_parameters=CreateCallParameters(sender=account.address)) + + signed_txn = atc.txn_list[0] + assert signed_txn.txn.sender == account.address + + +def test_create_parameters_rekey_to(client_fixture: ApplicationClient) -> None: + another_account_name = get_unique_name() + account = get_account(client_fixture.algod_client, another_account_name) + + atc = AtomicTransactionComposer() + client_fixture.compose_create(atc, "create", transaction_parameters=CreateCallParameters(rekey_to=account.address)) + + signed_txn = atc.txn_list[0] + assert signed_txn.txn.rekey_to == account.address + + +def test_create_parameters_suggested_params(client_fixture: ApplicationClient) -> None: + sp = client_fixture.algod_client.suggested_params() + sp.gen = "test-genesis" + + atc = AtomicTransactionComposer() + client_fixture.compose_create(atc, "create", transaction_parameters=CreateCallParameters(suggested_params=sp)) + + signed_txn = atc.txn_list[0] + assert signed_txn.txn.genesis_id == sp.gen + + +def test_create_parameters_boxes(client_fixture: ApplicationClient) -> None: + boxes = [(0, b"one"), (0, b"two")] + + atc = AtomicTransactionComposer() + client_fixture.compose_create(atc, "create", transaction_parameters=CreateCallParameters(boxes=boxes)) + + signed_txn = atc.txn_list[0] + assert isinstance(signed_txn.txn, ApplicationCallTxn) + assert [(b.app_index, b.name) for b in signed_txn.txn.boxes] == boxes + + +def test_create_parameters_accounts(client_fixture: ApplicationClient) -> None: + another_account_name = get_unique_name() + account = get_account(client_fixture.algod_client, another_account_name) + + atc = AtomicTransactionComposer() + client_fixture.compose_create( + atc, "create", transaction_parameters=CreateCallParameters(accounts=[account.address]) + ) + + signed_txn = atc.txn_list[0] + assert isinstance(signed_txn.txn, ApplicationCallTxn) + assert signed_txn.txn.accounts == [account.address] + + +def test_create_parameters_foreign_apps(client_fixture: ApplicationClient) -> None: + foreign_apps = [1, 2, 3] + + atc = AtomicTransactionComposer() + client_fixture.compose_create(atc, "create", transaction_parameters=CreateCallParameters(foreign_apps=foreign_apps)) + + signed_txn = atc.txn_list[0] + assert isinstance(signed_txn.txn, ApplicationCallTxn) + assert signed_txn.txn.foreign_apps == foreign_apps + + +def test_create_parameters_foreign_assets(client_fixture: ApplicationClient) -> None: + foreign_assets = [10, 20, 30] + + atc = AtomicTransactionComposer() + client_fixture.compose_create( + atc, "create", transaction_parameters=CreateCallParameters(foreign_assets=foreign_assets) + ) + + signed_txn = atc.txn_list[0] + assert isinstance(signed_txn.txn, ApplicationCallTxn) + assert signed_txn.txn.foreign_assets == foreign_assets diff --git a/tests/test_app_client_delete.approvals/test_abi_delete_args_fails.approved.txt b/tests/test_app_client_delete.approvals/test_abi_delete_args_fails.approved.txt new file mode 100644 index 0000000..c25585e --- /dev/null +++ b/tests/test_app_client_delete.approvals/test_abi_delete_args_fails.approved.txt @@ -0,0 +1,12 @@ +Txn {txn} had error 'assert failed pc=736' at PC 736 and Source Line 427: + + frame_dig -1 + extract 2 0 + bytec_2 // "Yes" + == + // passes delete check + assert <-- Error + intc 5 // deletable + // is deletable + assert + retsub \ No newline at end of file diff --git a/tests/test_app_client_delete.py b/tests/test_app_client_delete.py new file mode 100644 index 0000000..52b0b25 --- /dev/null +++ b/tests/test_app_client_delete.py @@ -0,0 +1,48 @@ +import pytest +from algokit_utils import ( + Account, + ApplicationClient, + ApplicationSpecification, + LogicError, +) +from algosdk.v2client.algod import AlgodClient +from algosdk.v2client.indexer import IndexerClient + +from tests.conftest import check_output_stability + + +@pytest.fixture +def client_fixture( + algod_client: AlgodClient, + indexer_client: IndexerClient, + funded_account: Account, + app_spec: ApplicationSpecification, +) -> ApplicationClient: + client = ApplicationClient(algod_client, app_spec, creator=funded_account, indexer_client=indexer_client) + client.create("create") + return client + + +def test_abi_delete(client_fixture: ApplicationClient) -> None: + delete_response = client_fixture.delete("delete") + + assert delete_response.tx_id + + +def test_bare_delete(client_fixture: ApplicationClient) -> None: + delete_response = client_fixture.delete(call_abi_method=False) + + assert delete_response.tx_id + + +def test_abi_delete_args(client_fixture: ApplicationClient) -> None: + delete_response = client_fixture.delete("delete_args", check="Yes") + + assert delete_response.tx_id + + +def test_abi_delete_args_fails(client_fixture: ApplicationClient) -> None: + with pytest.raises(LogicError) as ex: + client_fixture.delete("delete_args", check="No") + + check_output_stability(str(ex.value).replace(ex.value.transaction_id, "{txn}")) diff --git a/tests/test_app_client_deploy.py b/tests/test_app_client_deploy.py new file mode 100644 index 0000000..f6e3b71 --- /dev/null +++ b/tests/test_app_client_deploy.py @@ -0,0 +1,58 @@ +import pytest +from algokit_utils import ( + ABICreateCallArgs, + Account, + ApplicationClient, + ApplicationSpecification, + TransferParameters, + transfer, +) +from algosdk.v2client.algod import AlgodClient +from algosdk.v2client.indexer import IndexerClient + +from tests.conftest import get_unique_name, read_spec + + +@pytest.fixture +def client_fixture( + algod_client: AlgodClient, + indexer_client: IndexerClient, + funded_account: Account, +) -> ApplicationClient: + app_spec = read_spec("app_client_test.json", deletable=True, updatable=True, name=get_unique_name()) + client = ApplicationClient(algod_client, app_spec, creator=funded_account, indexer_client=indexer_client) + return client + + +def test_deploy_with_create(client_fixture: ApplicationClient, creator: Account) -> None: + client_fixture.deploy( + "v1", + create_args=ABICreateCallArgs( + method="create", + ), + ) + + transfer( + client_fixture.algod_client, + TransferParameters(from_account=creator, to_address=client_fixture.app_address, micro_algos=100_000), + ) + + assert client_fixture.call("hello", name="test").return_value == "Hello ABI, test" + + +def test_deploy_with_create_args(client_fixture: ApplicationClient, app_spec: ApplicationSpecification) -> None: + create_args = next(m for m in app_spec.contract.methods if m.name == "create_args") + client_fixture.deploy("v1", create_args=ABICreateCallArgs(method=create_args, args={"greeting": "deployed"})) + + assert client_fixture.call("hello", name="test").return_value == "deployed, test" + + +def test_deploy_with_bare_create(client_fixture: ApplicationClient) -> None: + client_fixture.deploy( + "v1", + create_args=ABICreateCallArgs( + method=False, + ), + ) + + assert client_fixture.call("hello", name="test").return_value == "Hello Bare, test" diff --git a/tests/test_app_client_opt_in.approvals/test_abi_update_args_fails.approved.txt b/tests/test_app_client_opt_in.approvals/test_abi_update_args_fails.approved.txt new file mode 100644 index 0000000..5a797ca --- /dev/null +++ b/tests/test_app_client_opt_in.approvals/test_abi_update_args_fails.approved.txt @@ -0,0 +1,12 @@ +Txn {txn} had error 'assert failed pc=964' at PC 964 and Source Line 571: + + frame_dig -1 + extract 2 0 + bytec_2 // "Yes" + == + // passes opt_in check + assert <-- Error + txn Sender + bytec_1 // "last" + pushbytes 0x4f707420496e2041726773 // "Opt In Args" + app_local_put \ No newline at end of file diff --git a/tests/test_app_client_opt_in.py b/tests/test_app_client_opt_in.py new file mode 100644 index 0000000..a76b167 --- /dev/null +++ b/tests/test_app_client_opt_in.py @@ -0,0 +1,55 @@ +import pytest +from algokit_utils import ( + Account, + ApplicationClient, + ApplicationSpecification, + LogicError, +) +from algosdk.v2client.algod import AlgodClient +from algosdk.v2client.indexer import IndexerClient + +from tests.conftest import check_output_stability, is_opted_in + + +@pytest.fixture +def client_fixture( + algod_client: AlgodClient, + indexer_client: IndexerClient, + app_spec: ApplicationSpecification, + funded_account: Account, +) -> ApplicationClient: + client = ApplicationClient(algod_client, app_spec, creator=funded_account, indexer_client=indexer_client) + client.create("create") + return client + + +def test_abi_opt_in(client_fixture: ApplicationClient) -> None: + opt_in_response = client_fixture.opt_in("opt_in") + + assert opt_in_response.tx_id + assert client_fixture.call("get_last").return_value == "Opt In ABI" + assert is_opted_in(client_fixture) + + +def test_bare_opt_in(client_fixture: ApplicationClient) -> None: + opt_in_response = client_fixture.opt_in(call_abi_method=False) + + assert opt_in_response.tx_id + assert client_fixture.call("get_last").return_value == "Opt In Bare" + assert is_opted_in(client_fixture) + + +def test_abi_opt_in_args(client_fixture: ApplicationClient) -> None: + update_response = client_fixture.opt_in("opt_in_args", check="Yes") + + assert update_response.tx_id + assert client_fixture.call("get_last").return_value == "Opt In Args" + assert is_opted_in(client_fixture) + + +def test_abi_update_args_fails(client_fixture: ApplicationClient) -> None: + with pytest.raises(LogicError) as ex: + client_fixture.opt_in("opt_in_args", check="No") + + check_output_stability(str(ex.value).replace(ex.value.transaction_id, "{txn}")) + assert not is_opted_in(client_fixture) diff --git a/tests/test_app_client_resolve.py b/tests/test_app_client_resolve.py new file mode 100644 index 0000000..7339f32 --- /dev/null +++ b/tests/test_app_client_resolve.py @@ -0,0 +1,51 @@ +from algokit_utils import ( + Account, + ApplicationClient, + DefaultArgumentDict, +) +from algosdk.v2client.algod import AlgodClient + +from tests.conftest import read_spec + + +def test_resolve(algod_client: AlgodClient, creator: Account) -> None: + app_spec = read_spec("app_resolve.json") + client_fixture = ApplicationClient(algod_client, app_spec, signer=creator) + client_fixture.create() + client_fixture.opt_in() + + int_default_argument: DefaultArgumentDict = {"source": "constant", "data": 1} + assert client_fixture.resolve(int_default_argument) == 1 + + string_default_argument: DefaultArgumentDict = {"source": "constant", "data": "stringy"} + assert client_fixture.resolve(string_default_argument) == "stringy" + + global_state_int_default_argument: DefaultArgumentDict = { + "source": "global-state", + "data": "global_state_val_int", + } + assert client_fixture.resolve(global_state_int_default_argument) == 1 + + global_state_byte_default_argument: DefaultArgumentDict = { + "source": "global-state", + "data": "global_state_val_byte", + } + assert client_fixture.resolve(global_state_byte_default_argument) == b"test" + + local_state_int_default_argument: DefaultArgumentDict = { + "source": "local-state", + "data": "acct_state_val_int", + } + assert client_fixture.resolve(local_state_int_default_argument) == 2 + + local_state_byte_default_argument: DefaultArgumentDict = { + "source": "local-state", + "data": "acct_state_val_byte", + } + assert client_fixture.resolve(local_state_byte_default_argument) == b"local-test" + + method_default_argument: DefaultArgumentDict = { + "source": "abi-method", + "data": {"name": "dummy", "args": [], "returns": {"type": "string"}}, + } + assert client_fixture.resolve(method_default_argument) == "deadbeef" diff --git a/tests/test_app_client_update.approvals/test_abi_update_args_fails.approved.txt b/tests/test_app_client_update.approvals/test_abi_update_args_fails.approved.txt new file mode 100644 index 0000000..db1e108 --- /dev/null +++ b/tests/test_app_client_update.approvals/test_abi_update_args_fails.approved.txt @@ -0,0 +1,12 @@ +Txn {txn} had error 'assert failed pc=673' at PC 673 and Source Line 379: + + frame_dig -1 + extract 2 0 + bytec_2 // "Yes" + == + // passes update check + assert <-- Error + intc 4 // updatable + // is updatable + assert + bytec_0 // "greeting" \ No newline at end of file diff --git a/tests/test_app_client_update.py b/tests/test_app_client_update.py new file mode 100644 index 0000000..9a210eb --- /dev/null +++ b/tests/test_app_client_update.py @@ -0,0 +1,51 @@ +import pytest +from algokit_utils import ( + Account, + ApplicationClient, + ApplicationSpecification, + LogicError, +) +from algosdk.v2client.algod import AlgodClient +from algosdk.v2client.indexer import IndexerClient + +from tests.conftest import check_output_stability + + +@pytest.fixture(scope="module") +def client_fixture( + algod_client: AlgodClient, + indexer_client: IndexerClient, + app_spec: ApplicationSpecification, + funded_account: Account, +) -> ApplicationClient: + client = ApplicationClient(algod_client, app_spec, creator=funded_account, indexer_client=indexer_client) + client.create("create") + return client + + +def test_abi_update(client_fixture: ApplicationClient) -> None: + update_response = client_fixture.update("update") + + assert update_response.tx_id + assert client_fixture.call("hello", name="test").return_value == "Updated ABI, test" + + +def test_bare_update(client_fixture: ApplicationClient) -> None: + update_response = client_fixture.update(call_abi_method=False) + + assert update_response.tx_id + assert client_fixture.call("hello", name="test").return_value == "Updated Bare, test" + + +def test_abi_update_args(client_fixture: ApplicationClient) -> None: + update_response = client_fixture.update("update_args", check="Yes") + + assert update_response.tx_id + assert client_fixture.call("hello", name="test").return_value == "Updated Args, test" + + +def test_abi_update_args_fails(client_fixture: ApplicationClient) -> None: + with pytest.raises(LogicError) as ex: + client_fixture.update("update_args", check="No") + + check_output_stability(str(ex.value).replace(ex.value.transaction_id, "{txn}")) diff --git a/tests/test_deploy_scenarios.approvals/test_template_substitution.approved.txt b/tests/test_deploy.approvals/test_template_substitution.approved.txt similarity index 100% rename from tests/test_deploy_scenarios.approvals/test_template_substitution.approved.txt rename to tests/test_deploy.approvals/test_template_substitution.approved.txt diff --git a/tests/test_deploy.py b/tests/test_deploy.py new file mode 100644 index 0000000..5487c48 --- /dev/null +++ b/tests/test_deploy.py @@ -0,0 +1,20 @@ +from algokit_utils import ( + replace_template_variables, +) + +from tests.conftest import check_output_stability + + +def test_template_substitution() -> None: + program = """ +test TMPL_INT // TMPL_INT +test TMPL_INT +no change +test TMPL_STR // TMPL_STR +TMPL_STR +TMPL_STR // TMPL_INT +TMPL_STR // foo // +TMPL_STR // bar +""" + result = replace_template_variables(program, {"INT": 123, "STR": "ABC"}) + check_output_stability(result) diff --git a/tests/test_deploy_scenarios.approvals/test_deploy_app_with_existing_deletable_app_succeeds.approved.txt b/tests/test_deploy_scenarios.approvals/test_deploy_app_with_existing_deletable_app_succeeds.approved.txt deleted file mode 100644 index c3bd784..0000000 --- a/tests/test_deploy_scenarios.approvals/test_deploy_app_with_existing_deletable_app_succeeds.approved.txt +++ /dev/null @@ -1,8 +0,0 @@ -INFO: SampleApp not found in {creator_account} account, deploying app. -INFO: SampleApp (1.0) deployed successfully, with app id {app0}. -DEBUG: SampleApp found in {creator_account} account, with app id {app0}, version=1.0. -INFO: Detected a TEAL update in app id {app0} -WARNING: App is not updatable and on_update=ReplaceApp, will attempt to create new app and delete old app -INFO: Replacing SampleApp (1.0) with SampleApp (2.0) in {creator_account} account. -INFO: SampleApp (2.0) deployed successfully, with app id {app1}. -INFO: SampleApp (1.0) with app id {app0}, deleted successfully. \ No newline at end of file diff --git a/tests/test_deploy_scenarios.approvals/test_deploy_app_with_existing_permanent_app_and_on_schema_break_equals_replace_app_fails.approved.txt b/tests/test_deploy_scenarios.approvals/test_deploy_app_with_existing_permanent_app_and_on_schema_break_equals_replace_app_fails.approved.txt index 761d83b..9f4a036 100644 --- a/tests/test_deploy_scenarios.approvals/test_deploy_app_with_existing_permanent_app_and_on_schema_break_equals_replace_app_fails.approved.txt +++ b/tests/test_deploy_scenarios.approvals/test_deploy_app_with_existing_permanent_app_and_on_schema_break_equals_replace_app_fails.approved.txt @@ -1,7 +1,7 @@ INFO: SampleApp not found in {creator_account} account, deploying app. INFO: SampleApp (1.0) deployed successfully, with app id {app0}. DEBUG: SampleApp found in {creator_account} account, with app id {app0}, version=1.0. -WARNING: Detected a breaking app schema change from: Global: uints=0, byte_slices=0, Local: uints=0, byte_slices=0 to Global: uints=1, byte_slices=0, Local: uints=0, byte_slices=0. +WARNING: Detected a breaking app schema change: Global uints increased from 0 to 1 WARNING: App is not deletable but on_schema_break=ReplaceApp, will attempt to delete app, delete will most likely fail INFO: Replacing SampleApp (1.0) with SampleApp (3.0) in {creator_account} account. ERROR: Deployment failed: assert failed pc=153 \ No newline at end of file diff --git a/tests/test_deploy_scenarios.approvals/test_deploy_app_with_existing_permanent_app_fails.approved.txt b/tests/test_deploy_scenarios.approvals/test_deploy_app_with_existing_permanent_app_fails.approved.txt index e466c9f..cc5ea20 100644 --- a/tests/test_deploy_scenarios.approvals/test_deploy_app_with_existing_permanent_app_fails.approved.txt +++ b/tests/test_deploy_scenarios.approvals/test_deploy_app_with_existing_permanent_app_fails.approved.txt @@ -1,5 +1,5 @@ INFO: SampleApp not found in {creator_account} account, deploying app. INFO: SampleApp (1.0) deployed successfully, with app id {app0}. DEBUG: SampleApp found in {creator_account} account, with app id {app0}, version=1.0. -WARNING: Detected a breaking app schema change from: Global: uints=0, byte_slices=0, Local: uints=0, byte_slices=0 to Global: uints=1, byte_slices=0, Local: uints=0, byte_slices=0. +WARNING: Detected a breaking app schema change: Global uints increased from 0 to 1 ERROR: DeploymentFailedError: Schema break detected and on_schema_break=OnSchemaBreak.Fail, stopping deployment. If you want to try deleting and recreating the app then re-run with on_schema_break=OnSchemaBreak.ReplaceApp \ No newline at end of file diff --git a/tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.Fail-Updatable.No-Deletable.No].approved.txt b/tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.Fail-Updatable.No-Deletable.No].approved.txt index e466c9f..cc5ea20 100644 --- a/tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.Fail-Updatable.No-Deletable.No].approved.txt +++ b/tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.Fail-Updatable.No-Deletable.No].approved.txt @@ -1,5 +1,5 @@ INFO: SampleApp not found in {creator_account} account, deploying app. INFO: SampleApp (1.0) deployed successfully, with app id {app0}. DEBUG: SampleApp found in {creator_account} account, with app id {app0}, version=1.0. -WARNING: Detected a breaking app schema change from: Global: uints=0, byte_slices=0, Local: uints=0, byte_slices=0 to Global: uints=1, byte_slices=0, Local: uints=0, byte_slices=0. +WARNING: Detected a breaking app schema change: Global uints increased from 0 to 1 ERROR: DeploymentFailedError: Schema break detected and on_schema_break=OnSchemaBreak.Fail, stopping deployment. If you want to try deleting and recreating the app then re-run with on_schema_break=OnSchemaBreak.ReplaceApp \ No newline at end of file diff --git a/tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.Fail-Updatable.No-Deletable.Yes].approved.txt b/tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.Fail-Updatable.No-Deletable.Yes].approved.txt index e466c9f..cc5ea20 100644 --- a/tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.Fail-Updatable.No-Deletable.Yes].approved.txt +++ b/tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.Fail-Updatable.No-Deletable.Yes].approved.txt @@ -1,5 +1,5 @@ INFO: SampleApp not found in {creator_account} account, deploying app. INFO: SampleApp (1.0) deployed successfully, with app id {app0}. DEBUG: SampleApp found in {creator_account} account, with app id {app0}, version=1.0. -WARNING: Detected a breaking app schema change from: Global: uints=0, byte_slices=0, Local: uints=0, byte_slices=0 to Global: uints=1, byte_slices=0, Local: uints=0, byte_slices=0. +WARNING: Detected a breaking app schema change: Global uints increased from 0 to 1 ERROR: DeploymentFailedError: Schema break detected and on_schema_break=OnSchemaBreak.Fail, stopping deployment. If you want to try deleting and recreating the app then re-run with on_schema_break=OnSchemaBreak.ReplaceApp \ No newline at end of file diff --git a/tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.Fail-Updatable.Yes-Deletable.No].approved.txt b/tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.Fail-Updatable.Yes-Deletable.No].approved.txt index e466c9f..cc5ea20 100644 --- a/tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.Fail-Updatable.Yes-Deletable.No].approved.txt +++ b/tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.Fail-Updatable.Yes-Deletable.No].approved.txt @@ -1,5 +1,5 @@ INFO: SampleApp not found in {creator_account} account, deploying app. INFO: SampleApp (1.0) deployed successfully, with app id {app0}. DEBUG: SampleApp found in {creator_account} account, with app id {app0}, version=1.0. -WARNING: Detected a breaking app schema change from: Global: uints=0, byte_slices=0, Local: uints=0, byte_slices=0 to Global: uints=1, byte_slices=0, Local: uints=0, byte_slices=0. +WARNING: Detected a breaking app schema change: Global uints increased from 0 to 1 ERROR: DeploymentFailedError: Schema break detected and on_schema_break=OnSchemaBreak.Fail, stopping deployment. If you want to try deleting and recreating the app then re-run with on_schema_break=OnSchemaBreak.ReplaceApp \ No newline at end of file diff --git a/tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.Fail-Updatable.Yes-Deletable.Yes].approved.txt b/tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.Fail-Updatable.Yes-Deletable.Yes].approved.txt index e466c9f..cc5ea20 100644 --- a/tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.Fail-Updatable.Yes-Deletable.Yes].approved.txt +++ b/tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.Fail-Updatable.Yes-Deletable.Yes].approved.txt @@ -1,5 +1,5 @@ INFO: SampleApp not found in {creator_account} account, deploying app. INFO: SampleApp (1.0) deployed successfully, with app id {app0}. DEBUG: SampleApp found in {creator_account} account, with app id {app0}, version=1.0. -WARNING: Detected a breaking app schema change from: Global: uints=0, byte_slices=0, Local: uints=0, byte_slices=0 to Global: uints=1, byte_slices=0, Local: uints=0, byte_slices=0. +WARNING: Detected a breaking app schema change: Global uints increased from 0 to 1 ERROR: DeploymentFailedError: Schema break detected and on_schema_break=OnSchemaBreak.Fail, stopping deployment. If you want to try deleting and recreating the app then re-run with on_schema_break=OnSchemaBreak.ReplaceApp \ No newline at end of file diff --git a/tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.ReplaceApp-Updatable.No-Deletable.No].approved.txt b/tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.ReplaceApp-Updatable.No-Deletable.No].approved.txt index e591c4d..d6059fe 100644 --- a/tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.ReplaceApp-Updatable.No-Deletable.No].approved.txt +++ b/tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.ReplaceApp-Updatable.No-Deletable.No].approved.txt @@ -1,7 +1,7 @@ INFO: SampleApp not found in {creator_account} account, deploying app. INFO: SampleApp (1.0) deployed successfully, with app id {app0}. DEBUG: SampleApp found in {creator_account} account, with app id {app0}, version=1.0. -WARNING: Detected a breaking app schema change from: Global: uints=0, byte_slices=0, Local: uints=0, byte_slices=0 to Global: uints=1, byte_slices=0, Local: uints=0, byte_slices=0. +WARNING: Detected a breaking app schema change: Global uints increased from 0 to 1 WARNING: App is not deletable but on_schema_break=ReplaceApp, will attempt to delete app, delete will most likely fail INFO: Replacing SampleApp (1.0) with SampleApp (3.0) in {creator_account} account. ERROR: LogicException: assert failed pc=153 \ No newline at end of file diff --git a/tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.ReplaceApp-Updatable.No-Deletable.Yes].approved.txt b/tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.ReplaceApp-Updatable.No-Deletable.Yes].approved.txt index 5c34930..5ffb772 100644 --- a/tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.ReplaceApp-Updatable.No-Deletable.Yes].approved.txt +++ b/tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.ReplaceApp-Updatable.No-Deletable.Yes].approved.txt @@ -1,7 +1,7 @@ INFO: SampleApp not found in {creator_account} account, deploying app. INFO: SampleApp (1.0) deployed successfully, with app id {app0}. DEBUG: SampleApp found in {creator_account} account, with app id {app0}, version=1.0. -WARNING: Detected a breaking app schema change from: Global: uints=0, byte_slices=0, Local: uints=0, byte_slices=0 to Global: uints=1, byte_slices=0, Local: uints=0, byte_slices=0. +WARNING: Detected a breaking app schema change: Global uints increased from 0 to 1 INFO: App is deletable and on_schema_break=ReplaceApp, will attempt to create new app and delete old app INFO: Replacing SampleApp (1.0) with SampleApp (3.0) in {creator_account} account. INFO: SampleApp (3.0) deployed successfully, with app id {app1}. diff --git a/tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.ReplaceApp-Updatable.Yes-Deletable.No].approved.txt b/tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.ReplaceApp-Updatable.Yes-Deletable.No].approved.txt index e591c4d..d6059fe 100644 --- a/tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.ReplaceApp-Updatable.Yes-Deletable.No].approved.txt +++ b/tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.ReplaceApp-Updatable.Yes-Deletable.No].approved.txt @@ -1,7 +1,7 @@ INFO: SampleApp not found in {creator_account} account, deploying app. INFO: SampleApp (1.0) deployed successfully, with app id {app0}. DEBUG: SampleApp found in {creator_account} account, with app id {app0}, version=1.0. -WARNING: Detected a breaking app schema change from: Global: uints=0, byte_slices=0, Local: uints=0, byte_slices=0 to Global: uints=1, byte_slices=0, Local: uints=0, byte_slices=0. +WARNING: Detected a breaking app schema change: Global uints increased from 0 to 1 WARNING: App is not deletable but on_schema_break=ReplaceApp, will attempt to delete app, delete will most likely fail INFO: Replacing SampleApp (1.0) with SampleApp (3.0) in {creator_account} account. ERROR: LogicException: assert failed pc=153 \ No newline at end of file diff --git a/tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.ReplaceApp-Updatable.Yes-Deletable.Yes].approved.txt b/tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.ReplaceApp-Updatable.Yes-Deletable.Yes].approved.txt index 5c34930..5ffb772 100644 --- a/tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.ReplaceApp-Updatable.Yes-Deletable.Yes].approved.txt +++ b/tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.ReplaceApp-Updatable.Yes-Deletable.Yes].approved.txt @@ -1,7 +1,7 @@ INFO: SampleApp not found in {creator_account} account, deploying app. INFO: SampleApp (1.0) deployed successfully, with app id {app0}. DEBUG: SampleApp found in {creator_account} account, with app id {app0}, version=1.0. -WARNING: Detected a breaking app schema change from: Global: uints=0, byte_slices=0, Local: uints=0, byte_slices=0 to Global: uints=1, byte_slices=0, Local: uints=0, byte_slices=0. +WARNING: Detected a breaking app schema change: Global uints increased from 0 to 1 INFO: App is deletable and on_schema_break=ReplaceApp, will attempt to create new app and delete old app INFO: Replacing SampleApp (1.0) with SampleApp (3.0) in {creator_account} account. INFO: SampleApp (3.0) deployed successfully, with app id {app1}. diff --git a/tests/test_deploy_scenarios.py b/tests/test_deploy_scenarios.py index 0b9f379..7b9117e 100644 --- a/tests/test_deploy_scenarios.py +++ b/tests/test_deploy_scenarios.py @@ -4,6 +4,7 @@ import pytest from algokit_utils import ( + Account, ApplicationClient, ApplicationSpecification, DeploymentFailedError, @@ -13,24 +14,31 @@ get_account, get_algod_client, get_indexer_client, - get_next_version, get_sandbox_default_account, - replace_template_variables, ) -from conftest import check_output_stability, get_specs, get_unique_name, read_spec + +from tests.conftest import check_output_stability, get_specs, get_unique_name, read_spec logger = logging.getLogger(__name__) class DeployFixture: - def __init__(self, caplog: pytest.LogCaptureFixture, request: pytest.FixtureRequest): + def __init__( + self, + caplog: pytest.LogCaptureFixture, + request: pytest.FixtureRequest, + creator_name: str, + creator: Account, + app_name: str, + ): self.app_ids: list[int] = [] self.caplog = caplog self.request = request self.algod_client = get_algod_client() self.indexer_client = get_indexer_client() - self.creator_name = get_unique_name() - self.creator = get_account(self.algod_client, self.creator_name) + self.creator_name = creator_name + self.creator = creator + self.app_name = app_name def deploy( self, @@ -56,10 +64,15 @@ def deploy( self.app_ids.append(app_client.app_id) return app_client - def check_log_stability(self, suffix: str = "") -> None: + def check_log_stability(self, replacements: dict[str, str] | None = None, suffix: str = "") -> None: + if replacements is None: + replacements = {} + replacements[self.app_name] = "SampleApp" records = self.caplog.get_records("call") logs = "\n".join(f"{r.levelname}: {r.message}" for r in records) logs = self._normalize_logs(logs) + for find, replace in (replacements or {}).items(): + logs = logs.replace(find, replace) check_output_stability(logs, test_name=self.request.node.name + suffix) def _normalize_logs(self, logs: str) -> str: @@ -79,14 +92,31 @@ def _wait_for_indexer_round(self, round_target: int, max_attempts: int = 100) -> break -@pytest.fixture() -def deploy_fixture(caplog: pytest.LogCaptureFixture, request: pytest.FixtureRequest) -> DeployFixture: +@pytest.fixture(scope="module") +def creator_name() -> str: + return get_unique_name() + + +@pytest.fixture(scope="module") +def creator(creator_name: str) -> Account: + return get_account(get_algod_client(), creator_name) + + +@pytest.fixture +def app_name() -> str: + return get_unique_name() + + +@pytest.fixture +def deploy_fixture( + caplog: pytest.LogCaptureFixture, request: pytest.FixtureRequest, creator_name: str, creator: Account, app_name: str +) -> DeployFixture: caplog.set_level(logging.DEBUG) - return DeployFixture(caplog, request) + return DeployFixture(caplog, request, creator_name=creator_name, creator=creator, app_name=app_name) -def test_deploy_app_with_no_existing_app_succeeds(deploy_fixture: DeployFixture) -> None: - v1, _, _ = get_specs() +def test_deploy_app_with_no_existing_app_succeeds(deploy_fixture: DeployFixture, app_name: str) -> None: + v1, _, _ = get_specs(name=app_name) app = deploy_fixture.deploy(v1, version="1.0", allow_update=False, allow_delete=False) @@ -94,8 +124,8 @@ def test_deploy_app_with_no_existing_app_succeeds(deploy_fixture: DeployFixture) deploy_fixture.check_log_stability() -def test_deploy_app_with_existing_updatable_app_succeeds(deploy_fixture: DeployFixture) -> None: - v1, v2, _ = get_specs() +def test_deploy_app_with_existing_updatable_app_succeeds(deploy_fixture: DeployFixture, app_name: str) -> None: + v1, v2, _ = get_specs(name=app_name) app_v1 = deploy_fixture.deploy(v1, version="1.0", allow_update=True, allow_delete=False) assert app_v1.app_id @@ -106,8 +136,8 @@ def test_deploy_app_with_existing_updatable_app_succeeds(deploy_fixture: DeployF deploy_fixture.check_log_stability() -def test_deploy_app_with_existing_immutable_app_fails(deploy_fixture: DeployFixture) -> None: - v1, v2, _ = get_specs() +def test_deploy_app_with_existing_immutable_app_fails(deploy_fixture: DeployFixture, app_name: str) -> None: + v1, v2, _ = get_specs(name=app_name) app_v1 = deploy_fixture.deploy(v1, version="1.0", allow_update=False, allow_delete=False) assert app_v1.app_id @@ -121,8 +151,9 @@ def test_deploy_app_with_existing_immutable_app_fails(deploy_fixture: DeployFixt def test_deploy_app_with_existing_immutable_app_and_on_update_equals_replace_app_succeeds( deploy_fixture: DeployFixture, + app_name: str, ) -> None: - v1, v2, _ = get_specs() + v1, v2, _ = get_specs(name=app_name) app_v1 = deploy_fixture.deploy(v1, version="1.0", allow_update=False, allow_delete=True) assert app_v1.app_id @@ -135,21 +166,8 @@ def test_deploy_app_with_existing_immutable_app_and_on_update_equals_replace_app deploy_fixture.check_log_stability() -def test_deploy_app_with_existing_deletable_app_succeeds(deploy_fixture: DeployFixture) -> None: - v1, v2, _ = get_specs() - - app_v1 = deploy_fixture.deploy(v1, version="1.0", allow_update=False, allow_delete=True) - assert app_v1.app_id - - app_v2 = deploy_fixture.deploy( - v2, version="2.0", allow_update=False, allow_delete=True, on_update=OnUpdate.ReplaceApp - ) - assert app_v1.app_id != app_v2.app_id - deploy_fixture.check_log_stability() - - -def test_deploy_app_with_existing_permanent_app_fails(deploy_fixture: DeployFixture) -> None: - v1, _, v3 = get_specs() +def test_deploy_app_with_existing_permanent_app_fails(deploy_fixture: DeployFixture, app_name: str) -> None: + v1, _, v3 = get_specs(name=app_name) app_v1 = deploy_fixture.deploy(v1, version="1.0", allow_update=False, allow_delete=False) assert app_v1.app_id @@ -160,8 +178,10 @@ def test_deploy_app_with_existing_permanent_app_fails(deploy_fixture: DeployFixt deploy_fixture.check_log_stability() -def test_deploy_app_with_existing_immutable_app_cannot_determine_if_updatable(deploy_fixture: DeployFixture) -> None: - v1, v2, _ = get_specs(updatable=False, deletable=False) +def test_deploy_app_with_existing_immutable_app_cannot_determine_if_updatable( + deploy_fixture: DeployFixture, app_name: str +) -> None: + v1, v2, _ = get_specs(updatable=False, deletable=False, name=app_name) app_v1 = deploy_fixture.deploy(v1) assert app_v1.app_id @@ -172,8 +192,10 @@ def test_deploy_app_with_existing_immutable_app_cannot_determine_if_updatable(de deploy_fixture.check_log_stability() -def test_deploy_app_with_existing_permanent_app_cannot_determine_if_deletable(deploy_fixture: DeployFixture) -> None: - v1, v2, _ = get_specs(updatable=False, deletable=False) +def test_deploy_app_with_existing_permanent_app_cannot_determine_if_deletable( + deploy_fixture: DeployFixture, app_name: str +) -> None: + v1, v2, _ = get_specs(updatable=False, deletable=False, name=app_name) app_v1 = deploy_fixture.deploy(v1) assert app_v1.app_id @@ -186,21 +208,25 @@ def test_deploy_app_with_existing_permanent_app_cannot_determine_if_deletable(de def test_deploy_app_with_existing_permanent_app_on_update_equals_replace_app_fails_and_doesnt_create_2nd_app( deploy_fixture: DeployFixture, + app_name: str, ) -> None: - v1, v2, _ = get_specs() + v1, v2, _ = get_specs(name=app_name) app_v1 = deploy_fixture.deploy(v1, version="1.0", allow_update=False, allow_delete=False) assert app_v1.app_id + apps_before = deploy_fixture.indexer_client.lookup_account_application_by_creator( + deploy_fixture.creator.address + ) # type: ignore[no-untyped-call] + with pytest.raises(LogicError) as error: deploy_fixture.deploy(v2, version="3.0", allow_update=False, allow_delete=False, on_update=OnUpdate.ReplaceApp) - lookup_response = deploy_fixture.indexer_client.lookup_account_application_by_creator( + apps_after = deploy_fixture.indexer_client.lookup_account_application_by_creator( deploy_fixture.creator.address ) # type: ignore[no-untyped-call] - all_apps = lookup_response["applications"] # ensure no other apps were created - assert len(all_apps) == 1 + assert len(apps_before["applications"]) == len(apps_after["applications"]) logger.error(f"DeploymentFailedError: {error.value.message}") deploy_fixture.check_log_stability() @@ -208,8 +234,9 @@ def test_deploy_app_with_existing_permanent_app_on_update_equals_replace_app_fai def test_deploy_app_with_existing_permanent_app_and_on_schema_break_equals_replace_app_fails( deploy_fixture: DeployFixture, + app_name: str, ) -> None: - v1, _, v3 = get_specs() + v1, _, v3 = get_specs(name=app_name) app_v1 = deploy_fixture.deploy(v1, allow_update=False, allow_delete=False, version="1.0") assert app_v1.app_id @@ -224,8 +251,8 @@ def test_deploy_app_with_existing_permanent_app_and_on_schema_break_equals_repla deploy_fixture.check_log_stability() -def test_deploy_templated_app_with_changing_parameters_succeeds(deploy_fixture: DeployFixture) -> None: - app_spec = read_spec("app_v1.json") +def test_deploy_templated_app_with_changing_parameters_succeeds(deploy_fixture: DeployFixture, app_name: str) -> None: + app_spec = read_spec("app_v1.json", name=app_name) logger.info("Deploy V1 as updatable, deletable") app_client = deploy_fixture.deploy( @@ -235,9 +262,8 @@ def test_deploy_templated_app_with_changing_parameters_succeeds(deploy_fixture: allow_update=True, ) - response = app_client.call("hello", args={"name": "call_1"}) - assert response.abi_result - logger.info(f"Called hello: {response.abi_result.return_value}") + response = app_client.call("hello", name="call_1") + logger.info(f"Called hello: {response.return_value}") logger.info("Deploy V2 as immutable, deletable") app_client = deploy_fixture.deploy( @@ -246,9 +272,8 @@ def test_deploy_templated_app_with_changing_parameters_succeeds(deploy_fixture: allow_update=False, ) - response = app_client.call("hello", args={"name": "call_2"}) - assert response.abi_result - logger.info(f"Called hello: {response.abi_result.return_value}") + response = app_client.call("hello", name="call_2") + logger.info(f"Called hello: {response.return_value}") logger.info("Attempt to deploy V3 as updatable, deletable, it will fail because V2 was immutable") with pytest.raises(LogicError) as exc_info: @@ -260,9 +285,8 @@ def test_deploy_templated_app_with_changing_parameters_succeeds(deploy_fixture: ) logger.error(f"LogicException: {exc_info.value.message}") - response = app_client.call("hello", args={"name": "call_3"}) - assert response.abi_result - logger.info(f"Called hello: {response.abi_result.return_value}") + response = app_client.call("hello", name="call_3") + logger.info(f"Called hello: {response.return_value}") logger.info("2nd Attempt to deploy V3 as updatable, deletable, it will succeed as on_update=OnUpdate.DeleteApp") # deploy with allow_delete=True, so we can replace it @@ -273,9 +297,8 @@ def test_deploy_templated_app_with_changing_parameters_succeeds(deploy_fixture: allow_delete=True, allow_update=True, ) - response = app_client.call("hello", args={"name": "call_4"}) - assert response.abi_result - logger.info(f"Called hello: {response.abi_result.return_value}") + response = app_client.call("hello", name="call_4") + logger.info(f"Called hello: {response.return_value}") app_id = app_client.app_id app_client = ApplicationClient( @@ -284,9 +307,8 @@ def test_deploy_templated_app_with_changing_parameters_succeeds(deploy_fixture: app_id=app_id, signer=deploy_fixture.creator, ) - response = app_client.call("hello", args={"name": "call_5"}) - assert response.abi_result - logger.info(f"Called hello: {response.abi_result.return_value}") + response = app_client.call("hello", name="call_5") + logger.info(f"Called hello: {response.return_value}") deploy_fixture.check_log_stability() @@ -310,9 +332,9 @@ def test_deploy_with_schema_breaking_change( deletable: Deletable, updatable: Updatable, on_schema_break: OnSchemaBreak, + app_name: str, ) -> None: - v1 = read_spec("app_v1.json") - v3 = read_spec("app_v3.json") + v1, _, v3 = get_specs(name=app_name) app_v1 = deploy_fixture.deploy( v1, version="1.0", allow_delete=deletable == Deletable.Yes, allow_update=updatable == Updatable.Yes @@ -344,9 +366,9 @@ def test_deploy_with_update( deletable: Deletable, updatable: Updatable, on_update: OnUpdate, + app_name: str, ) -> None: - v1 = read_spec("app_v1.json") - v2 = read_spec("app_v2.json") + v1, v2, _ = get_specs(name=app_name) app_v1 = deploy_fixture.deploy( v1, version="1.0", allow_delete=deletable == Deletable.Yes, allow_update=updatable == Updatable.Yes @@ -367,42 +389,3 @@ def test_deploy_with_update( logger.error(f"LogicException: {error.message}") deploy_fixture.check_log_stability() - - -def test_template_substitution() -> None: - program = """ -test TMPL_INT // TMPL_INT -test TMPL_INT -no change -test TMPL_STR // TMPL_STR -TMPL_STR -TMPL_STR // TMPL_INT -TMPL_STR // foo // -TMPL_STR // bar -""" - result = replace_template_variables(program, {"INT": 123, "STR": "ABC"}) - check_output_stability(result) - - -@pytest.mark.parametrize( - ("current", "expected_next"), - [ - ("1", "2"), - ("v1", "v2"), - ("v1-alpha", "v2-alpha"), - ("1.0", "1.1"), - ("v1.0", "v1.1"), - ("v1.0-alpha", "v1.1-alpha"), - ("1.0.0", "1.0.1"), - ("v1.0.0", "v1.0.1"), - ("v1.0.0-alpha", "v1.0.1-alpha"), - ], -) -def test_auto_version_increment(current: str, expected_next: str) -> None: - value = get_next_version(current) - assert value == expected_next - - -def test_auto_version_increment_failure() -> None: - with pytest.raises(DeploymentFailedError): - get_next_version("teapot") diff --git a/tests/test_transfer.approvals/test_transfer_max_fee_fails.approved.txt b/tests/test_transfer.approvals/test_transfer_max_fee_fails.approved.txt new file mode 100644 index 0000000..ebb6098 --- /dev/null +++ b/tests/test_transfer.approvals/test_transfer_max_fee_fails.approved.txt @@ -0,0 +1 @@ +Cancelled transaction due to high network congestion fees. Algorand suggested fees would cause this transaction to cost 1000 µALGOs. Cap for this transaction is 123 µALGOs. \ No newline at end of file diff --git a/tests/test_transfer.py b/tests/test_transfer.py index 82e39e0..e37c902 100644 --- a/tests/test_transfer.py +++ b/tests/test_transfer.py @@ -1,23 +1,62 @@ -from typing import Any, cast +import pytest +from algokit_utils import Account, TransferParameters, create_kmd_wallet_account, transfer +from algosdk.kmd import KMDClient +from algosdk.v2client.algod import AlgodClient -from algokit_utils import ABICallArgs, Account, ApplicationClient, TransferParameters, transfer +from tests.conftest import check_output_stability, get_unique_name -def test_transfer(client_fixture: ApplicationClient, creator: Account) -> None: - client_fixture.deploy( - "v1", - create_args=ABICallArgs( - method="create", - ), - ) +@pytest.fixture(scope="module") +def to_account(kmd_client: KMDClient) -> Account: + return create_kmd_wallet_account(kmd_client, get_unique_name()) + +def test_transfer(algod_client: AlgodClient, to_account: Account, funded_account: Account) -> None: requested_amount = 100_000 transfer( - TransferParameters(from_account=creator, to_address=client_fixture.app_address, amount=requested_amount), - client_fixture.algod_client, + algod_client, + TransferParameters( + from_account=funded_account, + to_address=to_account.address, + micro_algos=requested_amount, + ), ) - actual_amount = cast(dict[str, Any], client_fixture.algod_client.account_info(client_fixture.app_address)).get( - "amount" - ) + to_account_info = algod_client.account_info(to_account.address) + assert isinstance(to_account_info, dict) + actual_amount = to_account_info.get("amount") assert actual_amount == requested_amount + + +def test_transfer_max_fee_fails(algod_client: AlgodClient, to_account: Account, funded_account: Account) -> None: + requested_amount = 100_000 + max_fee = 123 + + with pytest.raises(Exception) as ex: + transfer( + algod_client, + TransferParameters( + from_account=funded_account, + to_address=to_account.address, + micro_algos=requested_amount, + max_fee_micro_algos=max_fee, + ), + ) + + check_output_stability(str(ex.value)) + + +def test_transfer_fee(algod_client: AlgodClient, to_account: Account, funded_account: Account) -> None: + requested_amount = 100_000 + fee = 1234 + txn = transfer( + algod_client, + TransferParameters( + from_account=funded_account, + to_address=to_account.address, + micro_algos=requested_amount, + fee_micro_algos=fee, + ), + ) + + assert txn.fee == fee