From 9389170ce20760e5de04ae0efd87cdb1afe10491 Mon Sep 17 00:00:00 2001 From: Altynbek Orumbayev Date: Tue, 22 Oct 2024 18:17:33 +0200 Subject: [PATCH 1/6] refactor: preparing codebase for gradual feature parity sync with utils-ts - Moving all v2 code into legacy_v2 folder inside src - Moving all v2 tests into legacy_v2_tests - Moving all files in beta into more granular folder structure --- .github/workflows/check-python.yaml | 19 +- legacy_v2_tests/__init__.py | 0 .../app_client_test.json | 0 {tests => legacy_v2_tests}/app_client_test.py | 0 .../app_multi_underscore_template_var.py | 0 {tests => legacy_v2_tests}/app_resolve.json | 0 {tests => legacy_v2_tests}/app_v1.json | 0 {tests => legacy_v2_tests}/app_v2.json | 0 {tests => legacy_v2_tests}/app_v3.json | 0 legacy_v2_tests/conftest.py | 211 +++ {tests => legacy_v2_tests}/test_account.py | 2 +- {tests => legacy_v2_tests}/test_app.py | 0 {tests => legacy_v2_tests}/test_app_client.py | 0 ...test_readonly_call_with_error.approved.txt | 0 ...ith_error_debug_mode_disabled.approved.txt | 0 ...rror_with_imported_source_map.approved.txt | 0 ...new_client_missing_source_map.approved.txt | 0 ...ew_client_provided_source_map.approved.txt | 0 ...ient_provided_template_values.approved.txt | 0 .../test_app_client_call.py | 4 +- .../test_app_client_clear_state.py | 2 +- ...test_abi_close_out_args_fails.approved.txt | 0 .../test_app_client_close_out.py | 2 +- ...st_create_auto_find_ambiguous.approved.txt | 0 .../test_app_client_create.py | 2 +- .../test_abi_delete_args_fails.approved.txt | 0 .../test_app_client_delete.py | 2 +- .../test_app_client_deploy.py | 2 +- .../test_abi_update_args_fails.approved.txt | 0 .../test_app_client_opt_in.py | 2 +- .../test_app_client_prepare.py | 0 .../test_app_client_resolve.py | 2 +- .../test_app_client_signer_sender.py | 0 .../test_app_client_template_values.py | 4 +- .../test_abi_update_args_fails.approved.txt | 0 .../test_app_client_update.py | 2 +- {tests => legacy_v2_tests}/test_asset.py | 2 +- .../test_build_teal_sourcemaps.approved.txt | 0 ...al_sourcemaps_without_sources.approved.txt | 0 .../test_debug_utils.py | 8 +- .../test_comment_stripping.approved.txt | 0 .../test_template_substitution.approved.txt | 0 {tests => legacy_v2_tests}/test_deploy.py | 4 +- ...e_equals_replace_app_succeeds.approved.txt | 0 ...cannot_determine_if_updatable.approved.txt | 0 ..._existing_immutable_app_fails.approved.txt | 0 ...reak_equals_replace_app_fails.approved.txt | 0 ...cannot_determine_if_deletable.approved.txt | 0 ..._existing_permanent_app_fails.approved.txt | 0 ...ils_and_doesnt_create_2nd_app.approved.txt | 0 ...isting_updatable_app_succeeds.approved.txt | 0 ...with_no_existing_app_succeeds.approved.txt | 0 ..._changing_parameters_succeeds.approved.txt | 0 ...il-Updatable.No-Deletable.No].approved.txt | 0 ...l-Updatable.No-Deletable.Yes].approved.txt | 0 ...l-Updatable.Yes-Deletable.No].approved.txt | 0 ...-Updatable.Yes-Deletable.Yes].approved.txt | 0 ...pp-Updatable.No-Deletable.No].approved.txt | 0 ...p-Updatable.No-Deletable.Yes].approved.txt | 0 ...p-Updatable.Yes-Deletable.No].approved.txt | 0 ...-Updatable.Yes-Deletable.Yes].approved.txt | 0 ...schema_breaking_change_append.approved.txt | 0 ...il-Updatable.No-Deletable.No].approved.txt | 0 ...l-Updatable.No-Deletable.Yes].approved.txt | 0 ...l-Updatable.Yes-Deletable.No].approved.txt | 0 ...-Updatable.Yes-Deletable.Yes].approved.txt | 0 ...pp-Updatable.No-Deletable.No].approved.txt | 0 ...p-Updatable.No-Deletable.Yes].approved.txt | 0 ...p-Updatable.Yes-Deletable.No].approved.txt | 0 ...-Updatable.Yes-Deletable.Yes].approved.txt | 0 ...pp-Updatable.No-Deletable.No].approved.txt | 0 ...p-Updatable.No-Deletable.Yes].approved.txt | 0 ...p-Updatable.Yes-Deletable.No].approved.txt | 0 ...-Updatable.Yes-Deletable.Yes].approved.txt | 0 ...est_deploy_with_update_append.approved.txt | 0 .../test_deploy_scenarios.py | 4 +- .../test_dispenser_api_client.py | 0 .../test_network_clients.py | 0 ...t_transfer_algo_max_fee_fails.approved.txt | 0 ..._transfer_asset_max_fee_fails.approved.txt | 0 {tests => legacy_v2_tests}/test_transfer.py | 4 +- poetry.lock | 24 +- pyproject.toml | 1 + src/algokit_utils/__init__.py | 45 +- src/algokit_utils/_debugging.py | 2 +- src/algokit_utils/_legacy_v2/__init__.py | 0 .../{ => _legacy_v2}/_ensure_funded.py | 10 +- .../{ => _legacy_v2}/_transfer.py | 2 +- src/algokit_utils/_legacy_v2/account.py | 183 +++ .../_legacy_v2/application_client.py | 1449 ++++++++++++++++ .../_legacy_v2/application_specification.py | 206 +++ src/algokit_utils/_legacy_v2/asset.py | 168 ++ src/algokit_utils/_legacy_v2/common.py | 28 + src/algokit_utils/_legacy_v2/deploy.py | 897 ++++++++++ src/algokit_utils/_legacy_v2/logic_error.py | 85 + src/algokit_utils/{ => _legacy_v2}/models.py | 4 +- .../_legacy_v2/network_clients.py | 130 ++ src/algokit_utils/account.py | 184 +-- src/algokit_utils/accounts/__init__.py | 0 .../{beta => accounts}/account_manager.py | 64 +- src/algokit_utils/accounts/models.py | 0 src/algokit_utils/application_client.py | 1450 +---------------- .../application_specification.py | 207 +-- src/algokit_utils/applications/__init__.py | 0 src/algokit_utils/applications/models.py | 0 src/algokit_utils/asset.py | 169 +- src/algokit_utils/assets/__init__.py | 0 src/algokit_utils/assets/models.py | 0 src/algokit_utils/clients/__init__.py | 0 .../{beta => clients}/algorand_client.py | 37 +- .../{beta => clients}/client_manager.py | 26 +- .../clients/dispenser_api_client.py | 178 ++ src/algokit_utils/clients/models.py | 0 src/algokit_utils/common.py | 29 +- src/algokit_utils/deploy.py | 898 +--------- src/algokit_utils/dispenser_api.py | 179 +- src/algokit_utils/errors/__init__.py | 0 src/algokit_utils/logic_error.py | 86 +- src/algokit_utils/models/__init__.py | 1 + src/algokit_utils/models/common.py | 0 src/algokit_utils/network_clients.py | 131 +- src/algokit_utils/transactions/__init__.py | 0 src/algokit_utils/transactions/models.py | 0 .../transaction_composer.py} | 110 +- tests/conftest.py | 2 +- tests/test_algorand_client.py | 4 +- 126 files changed, 3680 insertions(+), 3587 deletions(-) create mode 100644 legacy_v2_tests/__init__.py rename {tests => legacy_v2_tests}/app_client_test.json (100%) rename {tests => legacy_v2_tests}/app_client_test.py (100%) rename {tests => legacy_v2_tests}/app_multi_underscore_template_var.py (100%) rename {tests => legacy_v2_tests}/app_resolve.json (100%) rename {tests => legacy_v2_tests}/app_v1.json (100%) rename {tests => legacy_v2_tests}/app_v2.json (100%) rename {tests => legacy_v2_tests}/app_v3.json (100%) create mode 100644 legacy_v2_tests/conftest.py rename {tests => legacy_v2_tests}/test_account.py (88%) rename {tests => legacy_v2_tests}/test_app.py (100%) rename {tests => legacy_v2_tests}/test_app_client.py (100%) rename {tests => legacy_v2_tests}/test_app_client_call.approvals/test_readonly_call_with_error.approved.txt (100%) rename {tests => legacy_v2_tests}/test_app_client_call.approvals/test_readonly_call_with_error_debug_mode_disabled.approved.txt (100%) rename {tests => legacy_v2_tests}/test_app_client_call.approvals/test_readonly_call_with_error_with_imported_source_map.approved.txt (100%) rename {tests => legacy_v2_tests}/test_app_client_call.approvals/test_readonly_call_with_error_with_new_client_missing_source_map.approved.txt (100%) rename {tests => legacy_v2_tests}/test_app_client_call.approvals/test_readonly_call_with_error_with_new_client_provided_source_map.approved.txt (100%) rename {tests => legacy_v2_tests}/test_app_client_call.approvals/test_readonly_call_with_error_with_new_client_provided_template_values.approved.txt (100%) rename {tests => legacy_v2_tests}/test_app_client_call.py (98%) rename {tests => legacy_v2_tests}/test_app_client_clear_state.py (97%) rename {tests => legacy_v2_tests}/test_app_client_close_out.approvals/test_abi_close_out_args_fails.approved.txt (100%) rename {tests => legacy_v2_tests}/test_app_client_close_out.py (96%) rename {tests => legacy_v2_tests}/test_app_client_create.approvals/test_create_auto_find_ambiguous.approved.txt (100%) rename {tests => legacy_v2_tests}/test_app_client_create.py (99%) rename {tests => legacy_v2_tests}/test_app_client_delete.approvals/test_abi_delete_args_fails.approved.txt (100%) rename {tests => legacy_v2_tests}/test_app_client_delete.py (95%) rename {tests => legacy_v2_tests}/test_app_client_deploy.py (96%) rename {tests => legacy_v2_tests}/test_app_client_opt_in.approvals/test_abi_update_args_fails.approved.txt (100%) rename {tests => legacy_v2_tests}/test_app_client_opt_in.py (96%) rename {tests => legacy_v2_tests}/test_app_client_prepare.py (100%) rename {tests => legacy_v2_tests}/test_app_client_resolve.py (97%) rename {tests => legacy_v2_tests}/test_app_client_signer_sender.py (100%) rename {tests => legacy_v2_tests}/test_app_client_template_values.py (97%) rename {tests => legacy_v2_tests}/test_app_client_update.approvals/test_abi_update_args_fails.approved.txt (100%) rename {tests => legacy_v2_tests}/test_app_client_update.py (96%) rename {tests => legacy_v2_tests}/test_asset.py (98%) rename {tests => legacy_v2_tests}/test_debug_utils.approvals/test_build_teal_sourcemaps.approved.txt (100%) rename {tests => legacy_v2_tests}/test_debug_utils.approvals/test_build_teal_sourcemaps_without_sources.approved.txt (100%) rename {tests => legacy_v2_tests}/test_debug_utils.py (95%) rename {tests => legacy_v2_tests}/test_deploy.approvals/test_comment_stripping.approved.txt (100%) rename {tests => legacy_v2_tests}/test_deploy.approvals/test_template_substitution.approved.txt (100%) rename {tests => legacy_v2_tests}/test_deploy.py (93%) rename {tests => legacy_v2_tests}/test_deploy_scenarios.approvals/test_deploy_app_with_existing_immutable_app_and_on_update_equals_replace_app_succeeds.approved.txt (100%) rename {tests => legacy_v2_tests}/test_deploy_scenarios.approvals/test_deploy_app_with_existing_immutable_app_cannot_determine_if_updatable.approved.txt (100%) rename {tests => legacy_v2_tests}/test_deploy_scenarios.approvals/test_deploy_app_with_existing_immutable_app_fails.approved.txt (100%) rename {tests => legacy_v2_tests}/test_deploy_scenarios.approvals/test_deploy_app_with_existing_permanent_app_and_on_schema_break_equals_replace_app_fails.approved.txt (100%) rename {tests => legacy_v2_tests}/test_deploy_scenarios.approvals/test_deploy_app_with_existing_permanent_app_cannot_determine_if_deletable.approved.txt (100%) rename {tests => legacy_v2_tests}/test_deploy_scenarios.approvals/test_deploy_app_with_existing_permanent_app_fails.approved.txt (100%) rename {tests => legacy_v2_tests}/test_deploy_scenarios.approvals/test_deploy_app_with_existing_permanent_app_on_update_equals_replace_app_fails_and_doesnt_create_2nd_app.approved.txt (100%) rename {tests => legacy_v2_tests}/test_deploy_scenarios.approvals/test_deploy_app_with_existing_updatable_app_succeeds.approved.txt (100%) rename {tests => legacy_v2_tests}/test_deploy_scenarios.approvals/test_deploy_app_with_no_existing_app_succeeds.approved.txt (100%) rename {tests => legacy_v2_tests}/test_deploy_scenarios.approvals/test_deploy_templated_app_with_changing_parameters_succeeds.approved.txt (100%) rename {tests => legacy_v2_tests}/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.Fail-Updatable.No-Deletable.No].approved.txt (100%) rename {tests => legacy_v2_tests}/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.Fail-Updatable.No-Deletable.Yes].approved.txt (100%) rename {tests => legacy_v2_tests}/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.Fail-Updatable.Yes-Deletable.No].approved.txt (100%) rename {tests => legacy_v2_tests}/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.Fail-Updatable.Yes-Deletable.Yes].approved.txt (100%) rename {tests => legacy_v2_tests}/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.ReplaceApp-Updatable.No-Deletable.No].approved.txt (100%) rename {tests => legacy_v2_tests}/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.ReplaceApp-Updatable.No-Deletable.Yes].approved.txt (100%) rename {tests => legacy_v2_tests}/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.ReplaceApp-Updatable.Yes-Deletable.No].approved.txt (100%) rename {tests => legacy_v2_tests}/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.ReplaceApp-Updatable.Yes-Deletable.Yes].approved.txt (100%) rename {tests => legacy_v2_tests}/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change_append.approved.txt (100%) rename {tests => legacy_v2_tests}/test_deploy_scenarios.approvals/test_deploy_with_update[OnUpdate.Fail-Updatable.No-Deletable.No].approved.txt (100%) rename {tests => legacy_v2_tests}/test_deploy_scenarios.approvals/test_deploy_with_update[OnUpdate.Fail-Updatable.No-Deletable.Yes].approved.txt (100%) rename {tests => legacy_v2_tests}/test_deploy_scenarios.approvals/test_deploy_with_update[OnUpdate.Fail-Updatable.Yes-Deletable.No].approved.txt (100%) rename {tests => legacy_v2_tests}/test_deploy_scenarios.approvals/test_deploy_with_update[OnUpdate.Fail-Updatable.Yes-Deletable.Yes].approved.txt (100%) rename {tests => legacy_v2_tests}/test_deploy_scenarios.approvals/test_deploy_with_update[OnUpdate.ReplaceApp-Updatable.No-Deletable.No].approved.txt (100%) rename {tests => legacy_v2_tests}/test_deploy_scenarios.approvals/test_deploy_with_update[OnUpdate.ReplaceApp-Updatable.No-Deletable.Yes].approved.txt (100%) rename {tests => legacy_v2_tests}/test_deploy_scenarios.approvals/test_deploy_with_update[OnUpdate.ReplaceApp-Updatable.Yes-Deletable.No].approved.txt (100%) rename {tests => legacy_v2_tests}/test_deploy_scenarios.approvals/test_deploy_with_update[OnUpdate.ReplaceApp-Updatable.Yes-Deletable.Yes].approved.txt (100%) rename {tests => legacy_v2_tests}/test_deploy_scenarios.approvals/test_deploy_with_update[OnUpdate.UpdateApp-Updatable.No-Deletable.No].approved.txt (100%) rename {tests => legacy_v2_tests}/test_deploy_scenarios.approvals/test_deploy_with_update[OnUpdate.UpdateApp-Updatable.No-Deletable.Yes].approved.txt (100%) rename {tests => legacy_v2_tests}/test_deploy_scenarios.approvals/test_deploy_with_update[OnUpdate.UpdateApp-Updatable.Yes-Deletable.No].approved.txt (100%) rename {tests => legacy_v2_tests}/test_deploy_scenarios.approvals/test_deploy_with_update[OnUpdate.UpdateApp-Updatable.Yes-Deletable.Yes].approved.txt (100%) rename {tests => legacy_v2_tests}/test_deploy_scenarios.approvals/test_deploy_with_update_append.approved.txt (100%) rename {tests => legacy_v2_tests}/test_deploy_scenarios.py (98%) rename {tests => legacy_v2_tests}/test_dispenser_api_client.py (100%) rename {tests => legacy_v2_tests}/test_network_clients.py (100%) rename {tests => legacy_v2_tests}/test_transfer.approvals/test_transfer_algo_max_fee_fails.approved.txt (100%) rename {tests => legacy_v2_tests}/test_transfer.approvals/test_transfer_asset_max_fee_fails.approved.txt (100%) rename {tests => legacy_v2_tests}/test_transfer.py (98%) create mode 100644 src/algokit_utils/_legacy_v2/__init__.py rename src/algokit_utils/{ => _legacy_v2}/_ensure_funded.py (95%) rename src/algokit_utils/{ => _legacy_v2}/_transfer.py (99%) create mode 100644 src/algokit_utils/_legacy_v2/account.py create mode 100644 src/algokit_utils/_legacy_v2/application_client.py create mode 100644 src/algokit_utils/_legacy_v2/application_specification.py create mode 100644 src/algokit_utils/_legacy_v2/asset.py create mode 100644 src/algokit_utils/_legacy_v2/common.py create mode 100644 src/algokit_utils/_legacy_v2/deploy.py create mode 100644 src/algokit_utils/_legacy_v2/logic_error.py rename src/algokit_utils/{ => _legacy_v2}/models.py (97%) create mode 100644 src/algokit_utils/_legacy_v2/network_clients.py create mode 100644 src/algokit_utils/accounts/__init__.py rename src/algokit_utils/{beta => accounts}/account_manager.py (63%) create mode 100644 src/algokit_utils/accounts/models.py create mode 100644 src/algokit_utils/applications/__init__.py create mode 100644 src/algokit_utils/applications/models.py create mode 100644 src/algokit_utils/assets/__init__.py create mode 100644 src/algokit_utils/assets/models.py create mode 100644 src/algokit_utils/clients/__init__.py rename src/algokit_utils/{beta => clients}/algorand_client.py (96%) rename src/algokit_utils/{beta => clients}/client_manager.py (73%) create mode 100644 src/algokit_utils/clients/dispenser_api_client.py create mode 100644 src/algokit_utils/clients/models.py create mode 100644 src/algokit_utils/errors/__init__.py create mode 100644 src/algokit_utils/models/__init__.py create mode 100644 src/algokit_utils/models/common.py create mode 100644 src/algokit_utils/transactions/__init__.py create mode 100644 src/algokit_utils/transactions/models.py rename src/algokit_utils/{beta/composer.py => transactions/transaction_composer.py} (77%) diff --git a/.github/workflows/check-python.yaml b/.github/workflows/check-python.yaml index ea7a5d9f..e8464b76 100644 --- a/.github/workflows/check-python.yaml +++ b/.github/workflows/check-python.yaml @@ -45,12 +45,13 @@ jobs: - name: Check types with mypy run: poetry run mypy - - name: Check docs are up to date - run: | - poetry run poe docs - git diff --quiet --exit-code \ - ':!docs/html/_sources/apidocs/algokit_utils/algokit_utils.md.txt' \ - ':!docs/html/apidocs/algokit_utils/algokit_utils.html' \ - ':!docs/html/searchindex.js' \ - ':!docs/markdown/apidocs/algokit_utils/algokit_utils.md' \ - docs/ + # TODO: uncomment after bulk of feature parity with ts is addressed + # - name: Check docs are up to date + # run: | + # poetry run poe docs + # git diff --quiet --exit-code \ + # ':!docs/html/_sources/apidocs/algokit_utils/algokit_utils.md.txt' \ + # ':!docs/html/apidocs/algokit_utils/algokit_utils.html' \ + # ':!docs/html/searchindex.js' \ + # ':!docs/markdown/apidocs/algokit_utils/algokit_utils.md' \ + # docs/ diff --git a/legacy_v2_tests/__init__.py b/legacy_v2_tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/app_client_test.json b/legacy_v2_tests/app_client_test.json similarity index 100% rename from tests/app_client_test.json rename to legacy_v2_tests/app_client_test.json diff --git a/tests/app_client_test.py b/legacy_v2_tests/app_client_test.py similarity index 100% rename from tests/app_client_test.py rename to legacy_v2_tests/app_client_test.py diff --git a/tests/app_multi_underscore_template_var.py b/legacy_v2_tests/app_multi_underscore_template_var.py similarity index 100% rename from tests/app_multi_underscore_template_var.py rename to legacy_v2_tests/app_multi_underscore_template_var.py diff --git a/tests/app_resolve.json b/legacy_v2_tests/app_resolve.json similarity index 100% rename from tests/app_resolve.json rename to legacy_v2_tests/app_resolve.json diff --git a/tests/app_v1.json b/legacy_v2_tests/app_v1.json similarity index 100% rename from tests/app_v1.json rename to legacy_v2_tests/app_v1.json diff --git a/tests/app_v2.json b/legacy_v2_tests/app_v2.json similarity index 100% rename from tests/app_v2.json rename to legacy_v2_tests/app_v2.json diff --git a/tests/app_v3.json b/legacy_v2_tests/app_v3.json similarity index 100% rename from tests/app_v3.json rename to legacy_v2_tests/app_v3.json diff --git a/legacy_v2_tests/conftest.py b/legacy_v2_tests/conftest.py new file mode 100644 index 00000000..e3997a2c --- /dev/null +++ b/legacy_v2_tests/conftest.py @@ -0,0 +1,211 @@ +import inspect +import math +import random +import subprocess +from pathlib import Path +from typing import TYPE_CHECKING +from uuid import uuid4 + +import algosdk.transaction +import pytest +from algokit_utils import ( + DELETABLE_TEMPLATE_NAME, + UPDATABLE_TEMPLATE_NAME, + Account, + ApplicationClient, + ApplicationSpecification, + EnsureBalanceParameters, + ensure_funded, + get_account, + get_algod_client, + get_indexer_client, + get_kmd_client_from_algod_client, + replace_template_variables, +) +from dotenv import load_dotenv + +from legacy_v2_tests import app_client_test + +if TYPE_CHECKING: + from algosdk.kmd import KMDClient + from algosdk.v2client.algod import AlgodClient + from algosdk.v2client.indexer import IndexerClient + + +@pytest.fixture(autouse=True, scope="session") +def _environment_fixture() -> None: + env_path = Path(__file__).parent / ".." / "example.env" + load_dotenv(env_path) + + +def check_output_stability(logs: str, *, test_name: str | None = None) -> None: + """Test that the contract output hasn't changed for an Application, using git diff""" + caller_frame = inspect.stack()[1] + caller_path = Path(caller_frame.filename).resolve() + caller_dir = caller_path.parent + test_name = test_name or caller_frame.function + caller_stem = Path(caller_frame.filename).stem + output_dir = caller_dir / f"{caller_stem}.approvals" + output_dir.mkdir(exist_ok=True) + output_file = output_dir / f"{test_name}.approved.txt" + output_file_str = str(output_file) + output_file_did_exist = output_file.exists() + output_file.write_text(logs, encoding="utf-8") + + git_diff = subprocess.run( + [ + "git", + "diff", + "--exit-code", + "--no-ext-diff", + "--no-color", + output_file_str, + ], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + # first fail if there are any changes to already committed files, you must manually add them in that case + assert git_diff.returncode == 0, git_diff.stdout + + # if first time running, fail in case of accidental change to output directory + if not output_file_did_exist: + pytest.fail( + f"New output folder created at {output_file_str} from test {test_name} - " + "if this was intentional, please commit the files to the git repo" + ) + + +def read_spec( + file_name: str, + *, + updatable: bool | None = None, + deletable: bool | None = None, + template_values: dict | None = None, +) -> ApplicationSpecification: + path = Path(__file__).parent / file_name + spec = ApplicationSpecification.from_json(Path(path).read_text(encoding="utf-8")) + + template_variables = template_values or {} + if updatable is not None: + template_variables["UPDATABLE"] = int(updatable) + + if deletable is not None: + template_variables["DELETABLE"] = int(deletable) + + spec.approval_program = ( + replace_template_variables(spec.approval_program, template_variables) + .replace(f"// {UPDATABLE_TEMPLATE_NAME}", "// updatable") + .replace(f"// {DELETABLE_TEMPLATE_NAME}", "// deletable") + ) + return spec + + +def get_specs( + updatable: bool | None = None, + deletable: bool | None = None, +) -> tuple[ApplicationSpecification, ApplicationSpecification, ApplicationSpecification]: + return ( + 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), + ) + + +def get_unique_name() -> str: + name = str(uuid4()).replace("-", "") + assert name.isalnum() + return name + + +def is_opted_in(client_fixture: ApplicationClient) -> bool: + _, sender = client_fixture.resolve_signer_sender() + account_info = client_fixture.algod_client.account_info(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(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() + + +@pytest.fixture() +def creator(algod_client: "AlgodClient") -> Account: + creator_name = get_unique_name() + return get_account(algod_client, creator_name) + + +@pytest.fixture(scope="session") +def funded_account(algod_client: "AlgodClient") -> Account: + creator_name = get_unique_name() + return get_account(algod_client, creator_name) + + +@pytest.fixture(scope="session") +def app_spec() -> ApplicationSpecification: + app_spec = app_client_test.app.build() + path = Path(__file__).parent / "app_client_test.json" + path.write_text(app_spec.to_json()) + return read_spec("app_client_test.json", deletable=True, updatable=True, template_values={"VERSION": 1}) + + +def generate_test_asset(algod_client: "AlgodClient", sender: Account, total: int | None) -> int: + if total is None: + total = math.floor(random.random() * 100) + 20 + + decimals = 0 + asset_name = f"ASA ${math.floor(random.random() * 100) + 1}_${math.floor(random.random() * 100) + 1}_${total}" + + params = algod_client.suggested_params() + + txn = algosdk.transaction.AssetConfigTxn( + sender=sender.address, + sp=params, + total=total * 10**decimals, + decimals=decimals, + default_frozen=False, + unit_name="", + asset_name=asset_name, + manager=sender.address, + reserve=sender.address, + freeze=sender.address, + clawback=sender.address, + url="https://path/to/my/asset/details", + metadata_hash=None, + note=None, + lease=None, + rekey_to=None, + ) # type: ignore[no-untyped-call] + + signed_transaction = txn.sign(sender.private_key) # type: ignore[no-untyped-call] + algod_client.send_transaction(signed_transaction) + ptx = algod_client.pending_transaction_info(txn.get_txid()) # type: ignore[no-untyped-call] + + if isinstance(ptx, dict) and "asset-index" in ptx and isinstance(ptx["asset-index"], int): + return ptx["asset-index"] + else: + raise ValueError("Unexpected response from pending_transaction_info") + + +def assure_funds(algod_client: "AlgodClient", account: Account) -> None: + ensure_funded( + algod_client, + EnsureBalanceParameters( + account_to_fund=account, + min_spending_balance_micro_algos=300000, + min_funding_increment_micro_algos=1, + ), + ) diff --git a/tests/test_account.py b/legacy_v2_tests/test_account.py similarity index 88% rename from tests/test_account.py rename to legacy_v2_tests/test_account.py index 1536bd68..bb0ee272 100644 --- a/tests/test_account.py +++ b/legacy_v2_tests/test_account.py @@ -2,7 +2,7 @@ from algokit_utils import get_account -from tests.conftest import get_unique_name +from legacy_v2_tests.conftest import get_unique_name if TYPE_CHECKING: from algosdk.v2client.algod import AlgodClient diff --git a/tests/test_app.py b/legacy_v2_tests/test_app.py similarity index 100% rename from tests/test_app.py rename to legacy_v2_tests/test_app.py diff --git a/tests/test_app_client.py b/legacy_v2_tests/test_app_client.py similarity index 100% rename from tests/test_app_client.py rename to legacy_v2_tests/test_app_client.py diff --git a/tests/test_app_client_call.approvals/test_readonly_call_with_error.approved.txt b/legacy_v2_tests/test_app_client_call.approvals/test_readonly_call_with_error.approved.txt similarity index 100% rename from tests/test_app_client_call.approvals/test_readonly_call_with_error.approved.txt rename to legacy_v2_tests/test_app_client_call.approvals/test_readonly_call_with_error.approved.txt diff --git a/tests/test_app_client_call.approvals/test_readonly_call_with_error_debug_mode_disabled.approved.txt b/legacy_v2_tests/test_app_client_call.approvals/test_readonly_call_with_error_debug_mode_disabled.approved.txt similarity index 100% rename from tests/test_app_client_call.approvals/test_readonly_call_with_error_debug_mode_disabled.approved.txt rename to legacy_v2_tests/test_app_client_call.approvals/test_readonly_call_with_error_debug_mode_disabled.approved.txt diff --git a/tests/test_app_client_call.approvals/test_readonly_call_with_error_with_imported_source_map.approved.txt b/legacy_v2_tests/test_app_client_call.approvals/test_readonly_call_with_error_with_imported_source_map.approved.txt similarity index 100% rename from tests/test_app_client_call.approvals/test_readonly_call_with_error_with_imported_source_map.approved.txt rename to legacy_v2_tests/test_app_client_call.approvals/test_readonly_call_with_error_with_imported_source_map.approved.txt diff --git a/tests/test_app_client_call.approvals/test_readonly_call_with_error_with_new_client_missing_source_map.approved.txt b/legacy_v2_tests/test_app_client_call.approvals/test_readonly_call_with_error_with_new_client_missing_source_map.approved.txt similarity index 100% rename from tests/test_app_client_call.approvals/test_readonly_call_with_error_with_new_client_missing_source_map.approved.txt rename to legacy_v2_tests/test_app_client_call.approvals/test_readonly_call_with_error_with_new_client_missing_source_map.approved.txt diff --git a/tests/test_app_client_call.approvals/test_readonly_call_with_error_with_new_client_provided_source_map.approved.txt b/legacy_v2_tests/test_app_client_call.approvals/test_readonly_call_with_error_with_new_client_provided_source_map.approved.txt similarity index 100% rename from tests/test_app_client_call.approvals/test_readonly_call_with_error_with_new_client_provided_source_map.approved.txt rename to legacy_v2_tests/test_app_client_call.approvals/test_readonly_call_with_error_with_new_client_provided_source_map.approved.txt diff --git a/tests/test_app_client_call.approvals/test_readonly_call_with_error_with_new_client_provided_template_values.approved.txt b/legacy_v2_tests/test_app_client_call.approvals/test_readonly_call_with_error_with_new_client_provided_template_values.approved.txt similarity index 100% rename from tests/test_app_client_call.approvals/test_readonly_call_with_error_with_new_client_provided_template_values.approved.txt rename to legacy_v2_tests/test_app_client_call.approvals/test_readonly_call_with_error_with_new_client_provided_template_values.approved.txt diff --git a/tests/test_app_client_call.py b/legacy_v2_tests/test_app_client_call.py similarity index 98% rename from tests/test_app_client_call.py rename to legacy_v2_tests/test_app_client_call.py index 6d72f037..67acd4d5 100644 --- a/tests/test_app_client_call.py +++ b/legacy_v2_tests/test_app_client_call.py @@ -19,7 +19,7 @@ ) from algosdk.transaction import ApplicationCallTxn, PaymentTxn -from tests.conftest import check_output_stability, get_unique_name +from legacy_v2_tests.conftest import check_output_stability, get_unique_name if TYPE_CHECKING: from algosdk.abi import Method @@ -40,7 +40,7 @@ def client_fixture(algod_client: "AlgodClient", app_spec: ApplicationSpecificati # If you need to run a test without debug mode, you can reference this mock within the test and disable it explicitly. @pytest.fixture(autouse=True) def mock_config() -> Generator[Mock, None, None]: - with patch("algokit_utils.application_client.config", new_callable=Mock) as mock_config: + with patch("algokit_utils._legacy_v2.application_client.config", new_callable=Mock) as mock_config: mock_config.debug = True mock_config.project_root = None yield mock_config diff --git a/tests/test_app_client_clear_state.py b/legacy_v2_tests/test_app_client_clear_state.py similarity index 97% rename from tests/test_app_client_clear_state.py rename to legacy_v2_tests/test_app_client_clear_state.py index c8de6eba..f26a7094 100644 --- a/tests/test_app_client_clear_state.py +++ b/legacy_v2_tests/test_app_client_clear_state.py @@ -8,7 +8,7 @@ ApplicationSpecification, ) -from tests.conftest import is_opted_in +from legacy_v2_tests.conftest import is_opted_in if TYPE_CHECKING: from algosdk.v2client.algod import AlgodClient diff --git a/tests/test_app_client_close_out.approvals/test_abi_close_out_args_fails.approved.txt b/legacy_v2_tests/test_app_client_close_out.approvals/test_abi_close_out_args_fails.approved.txt similarity index 100% rename from tests/test_app_client_close_out.approvals/test_abi_close_out_args_fails.approved.txt rename to legacy_v2_tests/test_app_client_close_out.approvals/test_abi_close_out_args_fails.approved.txt diff --git a/tests/test_app_client_close_out.py b/legacy_v2_tests/test_app_client_close_out.py similarity index 96% rename from tests/test_app_client_close_out.py rename to legacy_v2_tests/test_app_client_close_out.py index b5ba1cd3..5ee5e9c6 100644 --- a/tests/test_app_client_close_out.py +++ b/legacy_v2_tests/test_app_client_close_out.py @@ -8,7 +8,7 @@ LogicError, ) -from tests.conftest import check_output_stability, is_opted_in +from legacy_v2_tests.conftest import check_output_stability, is_opted_in if TYPE_CHECKING: from algosdk.v2client.algod import AlgodClient diff --git a/tests/test_app_client_create.approvals/test_create_auto_find_ambiguous.approved.txt b/legacy_v2_tests/test_app_client_create.approvals/test_create_auto_find_ambiguous.approved.txt similarity index 100% rename from tests/test_app_client_create.approvals/test_create_auto_find_ambiguous.approved.txt rename to legacy_v2_tests/test_app_client_create.approvals/test_create_auto_find_ambiguous.approved.txt diff --git a/tests/test_app_client_create.py b/legacy_v2_tests/test_app_client_create.py similarity index 99% rename from tests/test_app_client_create.py rename to legacy_v2_tests/test_app_client_create.py index be29a5e4..1da7bbf7 100644 --- a/tests/test_app_client_create.py +++ b/legacy_v2_tests/test_app_client_create.py @@ -13,7 +13,7 @@ from algosdk.atomic_transaction_composer import AccountTransactionSigner, AtomicTransactionComposer, TransactionSigner from algosdk.transaction import ApplicationCallTxn, GenericSignedTransaction, OnComplete, Transaction -from tests.conftest import check_output_stability, get_unique_name +from legacy_v2_tests.conftest import check_output_stability, get_unique_name if TYPE_CHECKING: from algosdk.v2client.algod import AlgodClient diff --git a/tests/test_app_client_delete.approvals/test_abi_delete_args_fails.approved.txt b/legacy_v2_tests/test_app_client_delete.approvals/test_abi_delete_args_fails.approved.txt similarity index 100% rename from tests/test_app_client_delete.approvals/test_abi_delete_args_fails.approved.txt rename to legacy_v2_tests/test_app_client_delete.approvals/test_abi_delete_args_fails.approved.txt diff --git a/tests/test_app_client_delete.py b/legacy_v2_tests/test_app_client_delete.py similarity index 95% rename from tests/test_app_client_delete.py rename to legacy_v2_tests/test_app_client_delete.py index 6fc3ec5a..353bbfab 100644 --- a/tests/test_app_client_delete.py +++ b/legacy_v2_tests/test_app_client_delete.py @@ -8,7 +8,7 @@ LogicError, ) -from tests.conftest import check_output_stability +from legacy_v2_tests.conftest import check_output_stability if TYPE_CHECKING: from algosdk.v2client.algod import AlgodClient diff --git a/tests/test_app_client_deploy.py b/legacy_v2_tests/test_app_client_deploy.py similarity index 96% rename from tests/test_app_client_deploy.py rename to legacy_v2_tests/test_app_client_deploy.py index d1c8eba5..4eed49b6 100644 --- a/tests/test_app_client_deploy.py +++ b/legacy_v2_tests/test_app_client_deploy.py @@ -10,7 +10,7 @@ transfer, ) -from tests.conftest import get_unique_name, read_spec +from legacy_v2_tests.conftest import get_unique_name, read_spec if TYPE_CHECKING: from algosdk.v2client.algod import AlgodClient diff --git a/tests/test_app_client_opt_in.approvals/test_abi_update_args_fails.approved.txt b/legacy_v2_tests/test_app_client_opt_in.approvals/test_abi_update_args_fails.approved.txt similarity index 100% rename from tests/test_app_client_opt_in.approvals/test_abi_update_args_fails.approved.txt rename to legacy_v2_tests/test_app_client_opt_in.approvals/test_abi_update_args_fails.approved.txt diff --git a/tests/test_app_client_opt_in.py b/legacy_v2_tests/test_app_client_opt_in.py similarity index 96% rename from tests/test_app_client_opt_in.py rename to legacy_v2_tests/test_app_client_opt_in.py index 9244a826..816e96f0 100644 --- a/tests/test_app_client_opt_in.py +++ b/legacy_v2_tests/test_app_client_opt_in.py @@ -8,7 +8,7 @@ LogicError, ) -from tests.conftest import check_output_stability, is_opted_in +from legacy_v2_tests.conftest import check_output_stability, is_opted_in if TYPE_CHECKING: from algosdk.v2client.algod import AlgodClient diff --git a/tests/test_app_client_prepare.py b/legacy_v2_tests/test_app_client_prepare.py similarity index 100% rename from tests/test_app_client_prepare.py rename to legacy_v2_tests/test_app_client_prepare.py diff --git a/tests/test_app_client_resolve.py b/legacy_v2_tests/test_app_client_resolve.py similarity index 97% rename from tests/test_app_client_resolve.py rename to legacy_v2_tests/test_app_client_resolve.py index 2482149a..6c6023f3 100644 --- a/tests/test_app_client_resolve.py +++ b/legacy_v2_tests/test_app_client_resolve.py @@ -6,7 +6,7 @@ DefaultArgumentDict, ) -from tests.conftest import read_spec +from legacy_v2_tests.conftest import read_spec if TYPE_CHECKING: from algosdk.v2client.algod import AlgodClient diff --git a/tests/test_app_client_signer_sender.py b/legacy_v2_tests/test_app_client_signer_sender.py similarity index 100% rename from tests/test_app_client_signer_sender.py rename to legacy_v2_tests/test_app_client_signer_sender.py diff --git a/tests/test_app_client_template_values.py b/legacy_v2_tests/test_app_client_template_values.py similarity index 97% rename from tests/test_app_client_template_values.py rename to legacy_v2_tests/test_app_client_template_values.py index 0bf5ab70..5b27f320 100644 --- a/tests/test_app_client_template_values.py +++ b/legacy_v2_tests/test_app_client_template_values.py @@ -3,7 +3,7 @@ import algokit_utils import pytest -from tests.conftest import get_unique_name, read_spec +from legacy_v2_tests.conftest import get_unique_name, read_spec if TYPE_CHECKING: from algosdk.v2client.algod import AlgodClient @@ -153,7 +153,7 @@ def test_deploy_with_multi_underscore_template_value( indexer_client: "IndexerClient", funded_account: algokit_utils.Account, ) -> None: - from tests.app_multi_underscore_template_var import app + from legacy_v2_tests.app_multi_underscore_template_var import app some_value = 123 app_spec = app.build(algod_client) diff --git a/tests/test_app_client_update.approvals/test_abi_update_args_fails.approved.txt b/legacy_v2_tests/test_app_client_update.approvals/test_abi_update_args_fails.approved.txt similarity index 100% rename from tests/test_app_client_update.approvals/test_abi_update_args_fails.approved.txt rename to legacy_v2_tests/test_app_client_update.approvals/test_abi_update_args_fails.approved.txt diff --git a/tests/test_app_client_update.py b/legacy_v2_tests/test_app_client_update.py similarity index 96% rename from tests/test_app_client_update.py rename to legacy_v2_tests/test_app_client_update.py index 24dcf366..60cd10d9 100644 --- a/tests/test_app_client_update.py +++ b/legacy_v2_tests/test_app_client_update.py @@ -8,7 +8,7 @@ LogicError, ) -from tests.conftest import check_output_stability +from legacy_v2_tests.conftest import check_output_stability if TYPE_CHECKING: from algosdk.v2client.algod import AlgodClient diff --git a/tests/test_asset.py b/legacy_v2_tests/test_asset.py similarity index 98% rename from tests/test_asset.py rename to legacy_v2_tests/test_asset.py index d5612d26..3d75fa86 100644 --- a/tests/test_asset.py +++ b/legacy_v2_tests/test_asset.py @@ -16,7 +16,7 @@ from algosdk.kmd import KMDClient from algosdk.v2client.algod import AlgodClient -from tests.conftest import assure_funds, generate_test_asset, get_unique_name +from legacy_v2_tests.conftest import assure_funds, generate_test_asset, get_unique_name @pytest.fixture() diff --git a/tests/test_debug_utils.approvals/test_build_teal_sourcemaps.approved.txt b/legacy_v2_tests/test_debug_utils.approvals/test_build_teal_sourcemaps.approved.txt similarity index 100% rename from tests/test_debug_utils.approvals/test_build_teal_sourcemaps.approved.txt rename to legacy_v2_tests/test_debug_utils.approvals/test_build_teal_sourcemaps.approved.txt diff --git a/tests/test_debug_utils.approvals/test_build_teal_sourcemaps_without_sources.approved.txt b/legacy_v2_tests/test_debug_utils.approvals/test_build_teal_sourcemaps_without_sources.approved.txt similarity index 100% rename from tests/test_debug_utils.approvals/test_build_teal_sourcemaps_without_sources.approved.txt rename to legacy_v2_tests/test_debug_utils.approvals/test_build_teal_sourcemaps_without_sources.approved.txt diff --git a/tests/test_debug_utils.py b/legacy_v2_tests/test_debug_utils.py similarity index 95% rename from tests/test_debug_utils.py rename to legacy_v2_tests/test_debug_utils.py index 459bd126..9b6d8ca8 100644 --- a/tests/test_debug_utils.py +++ b/legacy_v2_tests/test_debug_utils.py @@ -9,8 +9,8 @@ persist_sourcemaps, simulate_and_persist_response, ) +from algokit_utils._legacy_v2.application_client import ApplicationClient from algokit_utils.account import get_account -from algokit_utils.application_client import ApplicationClient from algokit_utils.application_specification import ApplicationSpecification from algokit_utils.common import Program from algokit_utils.models import Account @@ -21,7 +21,7 @@ ) from algosdk.transaction import PaymentTxn -from tests.conftest import check_output_stability, get_unique_name +from legacy_v2_tests.conftest import check_output_stability, get_unique_name if TYPE_CHECKING: from algosdk.v2client.algod import AlgodClient @@ -123,7 +123,7 @@ def test_simulate_and_persist_response_via_app_call( client_fixture: ApplicationClient, mocker: Mock, ) -> None: - mock_config = mocker.patch("algokit_utils.application_client.config") + mock_config = mocker.patch("algokit_utils._legacy_v2.application_client.config") mock_config.debug = True mock_config.trace_all = True mock_config.trace_buffer_size_mb = 256 @@ -145,7 +145,7 @@ def test_simulate_and_persist_response_via_app_call( def test_simulate_and_persist_response( tmp_path_factory: pytest.TempPathFactory, client_fixture: ApplicationClient, mocker: Mock, funded_account: Account ) -> None: - mock_config = mocker.patch("algokit_utils.application_client.config") + mock_config = mocker.patch("algokit_utils._legacy_v2.application_client.config") mock_config.debug = True mock_config.trace_all = True cwd = tmp_path_factory.mktemp("cwd") diff --git a/tests/test_deploy.approvals/test_comment_stripping.approved.txt b/legacy_v2_tests/test_deploy.approvals/test_comment_stripping.approved.txt similarity index 100% rename from tests/test_deploy.approvals/test_comment_stripping.approved.txt rename to legacy_v2_tests/test_deploy.approvals/test_comment_stripping.approved.txt diff --git a/tests/test_deploy.approvals/test_template_substitution.approved.txt b/legacy_v2_tests/test_deploy.approvals/test_template_substitution.approved.txt similarity index 100% rename from tests/test_deploy.approvals/test_template_substitution.approved.txt rename to legacy_v2_tests/test_deploy.approvals/test_template_substitution.approved.txt diff --git a/tests/test_deploy.py b/legacy_v2_tests/test_deploy.py similarity index 93% rename from tests/test_deploy.py rename to legacy_v2_tests/test_deploy.py index 6a806f5d..51708f52 100644 --- a/tests/test_deploy.py +++ b/legacy_v2_tests/test_deploy.py @@ -1,9 +1,9 @@ from algokit_utils import ( replace_template_variables, ) -from algokit_utils.deploy import strip_comments +from algokit_utils._legacy_v2.deploy import strip_comments -from tests.conftest import check_output_stability +from legacy_v2_tests.conftest import check_output_stability def test_template_substitution() -> None: diff --git a/tests/test_deploy_scenarios.approvals/test_deploy_app_with_existing_immutable_app_and_on_update_equals_replace_app_succeeds.approved.txt b/legacy_v2_tests/test_deploy_scenarios.approvals/test_deploy_app_with_existing_immutable_app_and_on_update_equals_replace_app_succeeds.approved.txt similarity index 100% rename from tests/test_deploy_scenarios.approvals/test_deploy_app_with_existing_immutable_app_and_on_update_equals_replace_app_succeeds.approved.txt rename to legacy_v2_tests/test_deploy_scenarios.approvals/test_deploy_app_with_existing_immutable_app_and_on_update_equals_replace_app_succeeds.approved.txt diff --git a/tests/test_deploy_scenarios.approvals/test_deploy_app_with_existing_immutable_app_cannot_determine_if_updatable.approved.txt b/legacy_v2_tests/test_deploy_scenarios.approvals/test_deploy_app_with_existing_immutable_app_cannot_determine_if_updatable.approved.txt similarity index 100% rename from tests/test_deploy_scenarios.approvals/test_deploy_app_with_existing_immutable_app_cannot_determine_if_updatable.approved.txt rename to legacy_v2_tests/test_deploy_scenarios.approvals/test_deploy_app_with_existing_immutable_app_cannot_determine_if_updatable.approved.txt diff --git a/tests/test_deploy_scenarios.approvals/test_deploy_app_with_existing_immutable_app_fails.approved.txt b/legacy_v2_tests/test_deploy_scenarios.approvals/test_deploy_app_with_existing_immutable_app_fails.approved.txt similarity index 100% rename from tests/test_deploy_scenarios.approvals/test_deploy_app_with_existing_immutable_app_fails.approved.txt rename to legacy_v2_tests/test_deploy_scenarios.approvals/test_deploy_app_with_existing_immutable_app_fails.approved.txt 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/legacy_v2_tests/test_deploy_scenarios.approvals/test_deploy_app_with_existing_permanent_app_and_on_schema_break_equals_replace_app_fails.approved.txt similarity index 100% rename from tests/test_deploy_scenarios.approvals/test_deploy_app_with_existing_permanent_app_and_on_schema_break_equals_replace_app_fails.approved.txt rename to legacy_v2_tests/test_deploy_scenarios.approvals/test_deploy_app_with_existing_permanent_app_and_on_schema_break_equals_replace_app_fails.approved.txt diff --git a/tests/test_deploy_scenarios.approvals/test_deploy_app_with_existing_permanent_app_cannot_determine_if_deletable.approved.txt b/legacy_v2_tests/test_deploy_scenarios.approvals/test_deploy_app_with_existing_permanent_app_cannot_determine_if_deletable.approved.txt similarity index 100% rename from tests/test_deploy_scenarios.approvals/test_deploy_app_with_existing_permanent_app_cannot_determine_if_deletable.approved.txt rename to legacy_v2_tests/test_deploy_scenarios.approvals/test_deploy_app_with_existing_permanent_app_cannot_determine_if_deletable.approved.txt diff --git a/tests/test_deploy_scenarios.approvals/test_deploy_app_with_existing_permanent_app_fails.approved.txt b/legacy_v2_tests/test_deploy_scenarios.approvals/test_deploy_app_with_existing_permanent_app_fails.approved.txt similarity index 100% rename from tests/test_deploy_scenarios.approvals/test_deploy_app_with_existing_permanent_app_fails.approved.txt rename to legacy_v2_tests/test_deploy_scenarios.approvals/test_deploy_app_with_existing_permanent_app_fails.approved.txt diff --git a/tests/test_deploy_scenarios.approvals/test_deploy_app_with_existing_permanent_app_on_update_equals_replace_app_fails_and_doesnt_create_2nd_app.approved.txt b/legacy_v2_tests/test_deploy_scenarios.approvals/test_deploy_app_with_existing_permanent_app_on_update_equals_replace_app_fails_and_doesnt_create_2nd_app.approved.txt similarity index 100% rename from tests/test_deploy_scenarios.approvals/test_deploy_app_with_existing_permanent_app_on_update_equals_replace_app_fails_and_doesnt_create_2nd_app.approved.txt rename to legacy_v2_tests/test_deploy_scenarios.approvals/test_deploy_app_with_existing_permanent_app_on_update_equals_replace_app_fails_and_doesnt_create_2nd_app.approved.txt diff --git a/tests/test_deploy_scenarios.approvals/test_deploy_app_with_existing_updatable_app_succeeds.approved.txt b/legacy_v2_tests/test_deploy_scenarios.approvals/test_deploy_app_with_existing_updatable_app_succeeds.approved.txt similarity index 100% rename from tests/test_deploy_scenarios.approvals/test_deploy_app_with_existing_updatable_app_succeeds.approved.txt rename to legacy_v2_tests/test_deploy_scenarios.approvals/test_deploy_app_with_existing_updatable_app_succeeds.approved.txt diff --git a/tests/test_deploy_scenarios.approvals/test_deploy_app_with_no_existing_app_succeeds.approved.txt b/legacy_v2_tests/test_deploy_scenarios.approvals/test_deploy_app_with_no_existing_app_succeeds.approved.txt similarity index 100% rename from tests/test_deploy_scenarios.approvals/test_deploy_app_with_no_existing_app_succeeds.approved.txt rename to legacy_v2_tests/test_deploy_scenarios.approvals/test_deploy_app_with_no_existing_app_succeeds.approved.txt diff --git a/tests/test_deploy_scenarios.approvals/test_deploy_templated_app_with_changing_parameters_succeeds.approved.txt b/legacy_v2_tests/test_deploy_scenarios.approvals/test_deploy_templated_app_with_changing_parameters_succeeds.approved.txt similarity index 100% rename from tests/test_deploy_scenarios.approvals/test_deploy_templated_app_with_changing_parameters_succeeds.approved.txt rename to legacy_v2_tests/test_deploy_scenarios.approvals/test_deploy_templated_app_with_changing_parameters_succeeds.approved.txt diff --git a/tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.Fail-Updatable.No-Deletable.No].approved.txt b/legacy_v2_tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.Fail-Updatable.No-Deletable.No].approved.txt similarity index 100% rename from tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.Fail-Updatable.No-Deletable.No].approved.txt rename to legacy_v2_tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.Fail-Updatable.No-Deletable.No].approved.txt diff --git a/tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.Fail-Updatable.No-Deletable.Yes].approved.txt b/legacy_v2_tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.Fail-Updatable.No-Deletable.Yes].approved.txt similarity index 100% rename from tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.Fail-Updatable.No-Deletable.Yes].approved.txt rename to legacy_v2_tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.Fail-Updatable.No-Deletable.Yes].approved.txt diff --git a/tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.Fail-Updatable.Yes-Deletable.No].approved.txt b/legacy_v2_tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.Fail-Updatable.Yes-Deletable.No].approved.txt similarity index 100% rename from tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.Fail-Updatable.Yes-Deletable.No].approved.txt rename to legacy_v2_tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.Fail-Updatable.Yes-Deletable.No].approved.txt diff --git a/tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.Fail-Updatable.Yes-Deletable.Yes].approved.txt b/legacy_v2_tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.Fail-Updatable.Yes-Deletable.Yes].approved.txt similarity index 100% rename from tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.Fail-Updatable.Yes-Deletable.Yes].approved.txt rename to legacy_v2_tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.Fail-Updatable.Yes-Deletable.Yes].approved.txt diff --git a/tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.ReplaceApp-Updatable.No-Deletable.No].approved.txt b/legacy_v2_tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.ReplaceApp-Updatable.No-Deletable.No].approved.txt similarity index 100% rename from tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.ReplaceApp-Updatable.No-Deletable.No].approved.txt rename to legacy_v2_tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.ReplaceApp-Updatable.No-Deletable.No].approved.txt diff --git a/tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.ReplaceApp-Updatable.No-Deletable.Yes].approved.txt b/legacy_v2_tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.ReplaceApp-Updatable.No-Deletable.Yes].approved.txt similarity index 100% rename from tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.ReplaceApp-Updatable.No-Deletable.Yes].approved.txt rename to legacy_v2_tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.ReplaceApp-Updatable.No-Deletable.Yes].approved.txt diff --git a/tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.ReplaceApp-Updatable.Yes-Deletable.No].approved.txt b/legacy_v2_tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.ReplaceApp-Updatable.Yes-Deletable.No].approved.txt similarity index 100% rename from tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.ReplaceApp-Updatable.Yes-Deletable.No].approved.txt rename to legacy_v2_tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.ReplaceApp-Updatable.Yes-Deletable.No].approved.txt diff --git a/tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.ReplaceApp-Updatable.Yes-Deletable.Yes].approved.txt b/legacy_v2_tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.ReplaceApp-Updatable.Yes-Deletable.Yes].approved.txt similarity index 100% rename from tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.ReplaceApp-Updatable.Yes-Deletable.Yes].approved.txt rename to legacy_v2_tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change[OnSchemaBreak.ReplaceApp-Updatable.Yes-Deletable.Yes].approved.txt diff --git a/tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change_append.approved.txt b/legacy_v2_tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change_append.approved.txt similarity index 100% rename from tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change_append.approved.txt rename to legacy_v2_tests/test_deploy_scenarios.approvals/test_deploy_with_schema_breaking_change_append.approved.txt diff --git a/tests/test_deploy_scenarios.approvals/test_deploy_with_update[OnUpdate.Fail-Updatable.No-Deletable.No].approved.txt b/legacy_v2_tests/test_deploy_scenarios.approvals/test_deploy_with_update[OnUpdate.Fail-Updatable.No-Deletable.No].approved.txt similarity index 100% rename from tests/test_deploy_scenarios.approvals/test_deploy_with_update[OnUpdate.Fail-Updatable.No-Deletable.No].approved.txt rename to legacy_v2_tests/test_deploy_scenarios.approvals/test_deploy_with_update[OnUpdate.Fail-Updatable.No-Deletable.No].approved.txt diff --git a/tests/test_deploy_scenarios.approvals/test_deploy_with_update[OnUpdate.Fail-Updatable.No-Deletable.Yes].approved.txt b/legacy_v2_tests/test_deploy_scenarios.approvals/test_deploy_with_update[OnUpdate.Fail-Updatable.No-Deletable.Yes].approved.txt similarity index 100% rename from tests/test_deploy_scenarios.approvals/test_deploy_with_update[OnUpdate.Fail-Updatable.No-Deletable.Yes].approved.txt rename to legacy_v2_tests/test_deploy_scenarios.approvals/test_deploy_with_update[OnUpdate.Fail-Updatable.No-Deletable.Yes].approved.txt diff --git a/tests/test_deploy_scenarios.approvals/test_deploy_with_update[OnUpdate.Fail-Updatable.Yes-Deletable.No].approved.txt b/legacy_v2_tests/test_deploy_scenarios.approvals/test_deploy_with_update[OnUpdate.Fail-Updatable.Yes-Deletable.No].approved.txt similarity index 100% rename from tests/test_deploy_scenarios.approvals/test_deploy_with_update[OnUpdate.Fail-Updatable.Yes-Deletable.No].approved.txt rename to legacy_v2_tests/test_deploy_scenarios.approvals/test_deploy_with_update[OnUpdate.Fail-Updatable.Yes-Deletable.No].approved.txt diff --git a/tests/test_deploy_scenarios.approvals/test_deploy_with_update[OnUpdate.Fail-Updatable.Yes-Deletable.Yes].approved.txt b/legacy_v2_tests/test_deploy_scenarios.approvals/test_deploy_with_update[OnUpdate.Fail-Updatable.Yes-Deletable.Yes].approved.txt similarity index 100% rename from tests/test_deploy_scenarios.approvals/test_deploy_with_update[OnUpdate.Fail-Updatable.Yes-Deletable.Yes].approved.txt rename to legacy_v2_tests/test_deploy_scenarios.approvals/test_deploy_with_update[OnUpdate.Fail-Updatable.Yes-Deletable.Yes].approved.txt diff --git a/tests/test_deploy_scenarios.approvals/test_deploy_with_update[OnUpdate.ReplaceApp-Updatable.No-Deletable.No].approved.txt b/legacy_v2_tests/test_deploy_scenarios.approvals/test_deploy_with_update[OnUpdate.ReplaceApp-Updatable.No-Deletable.No].approved.txt similarity index 100% rename from tests/test_deploy_scenarios.approvals/test_deploy_with_update[OnUpdate.ReplaceApp-Updatable.No-Deletable.No].approved.txt rename to legacy_v2_tests/test_deploy_scenarios.approvals/test_deploy_with_update[OnUpdate.ReplaceApp-Updatable.No-Deletable.No].approved.txt diff --git a/tests/test_deploy_scenarios.approvals/test_deploy_with_update[OnUpdate.ReplaceApp-Updatable.No-Deletable.Yes].approved.txt b/legacy_v2_tests/test_deploy_scenarios.approvals/test_deploy_with_update[OnUpdate.ReplaceApp-Updatable.No-Deletable.Yes].approved.txt similarity index 100% rename from tests/test_deploy_scenarios.approvals/test_deploy_with_update[OnUpdate.ReplaceApp-Updatable.No-Deletable.Yes].approved.txt rename to legacy_v2_tests/test_deploy_scenarios.approvals/test_deploy_with_update[OnUpdate.ReplaceApp-Updatable.No-Deletable.Yes].approved.txt diff --git a/tests/test_deploy_scenarios.approvals/test_deploy_with_update[OnUpdate.ReplaceApp-Updatable.Yes-Deletable.No].approved.txt b/legacy_v2_tests/test_deploy_scenarios.approvals/test_deploy_with_update[OnUpdate.ReplaceApp-Updatable.Yes-Deletable.No].approved.txt similarity index 100% rename from tests/test_deploy_scenarios.approvals/test_deploy_with_update[OnUpdate.ReplaceApp-Updatable.Yes-Deletable.No].approved.txt rename to legacy_v2_tests/test_deploy_scenarios.approvals/test_deploy_with_update[OnUpdate.ReplaceApp-Updatable.Yes-Deletable.No].approved.txt diff --git a/tests/test_deploy_scenarios.approvals/test_deploy_with_update[OnUpdate.ReplaceApp-Updatable.Yes-Deletable.Yes].approved.txt b/legacy_v2_tests/test_deploy_scenarios.approvals/test_deploy_with_update[OnUpdate.ReplaceApp-Updatable.Yes-Deletable.Yes].approved.txt similarity index 100% rename from tests/test_deploy_scenarios.approvals/test_deploy_with_update[OnUpdate.ReplaceApp-Updatable.Yes-Deletable.Yes].approved.txt rename to legacy_v2_tests/test_deploy_scenarios.approvals/test_deploy_with_update[OnUpdate.ReplaceApp-Updatable.Yes-Deletable.Yes].approved.txt diff --git a/tests/test_deploy_scenarios.approvals/test_deploy_with_update[OnUpdate.UpdateApp-Updatable.No-Deletable.No].approved.txt b/legacy_v2_tests/test_deploy_scenarios.approvals/test_deploy_with_update[OnUpdate.UpdateApp-Updatable.No-Deletable.No].approved.txt similarity index 100% rename from tests/test_deploy_scenarios.approvals/test_deploy_with_update[OnUpdate.UpdateApp-Updatable.No-Deletable.No].approved.txt rename to legacy_v2_tests/test_deploy_scenarios.approvals/test_deploy_with_update[OnUpdate.UpdateApp-Updatable.No-Deletable.No].approved.txt diff --git a/tests/test_deploy_scenarios.approvals/test_deploy_with_update[OnUpdate.UpdateApp-Updatable.No-Deletable.Yes].approved.txt b/legacy_v2_tests/test_deploy_scenarios.approvals/test_deploy_with_update[OnUpdate.UpdateApp-Updatable.No-Deletable.Yes].approved.txt similarity index 100% rename from tests/test_deploy_scenarios.approvals/test_deploy_with_update[OnUpdate.UpdateApp-Updatable.No-Deletable.Yes].approved.txt rename to legacy_v2_tests/test_deploy_scenarios.approvals/test_deploy_with_update[OnUpdate.UpdateApp-Updatable.No-Deletable.Yes].approved.txt diff --git a/tests/test_deploy_scenarios.approvals/test_deploy_with_update[OnUpdate.UpdateApp-Updatable.Yes-Deletable.No].approved.txt b/legacy_v2_tests/test_deploy_scenarios.approvals/test_deploy_with_update[OnUpdate.UpdateApp-Updatable.Yes-Deletable.No].approved.txt similarity index 100% rename from tests/test_deploy_scenarios.approvals/test_deploy_with_update[OnUpdate.UpdateApp-Updatable.Yes-Deletable.No].approved.txt rename to legacy_v2_tests/test_deploy_scenarios.approvals/test_deploy_with_update[OnUpdate.UpdateApp-Updatable.Yes-Deletable.No].approved.txt diff --git a/tests/test_deploy_scenarios.approvals/test_deploy_with_update[OnUpdate.UpdateApp-Updatable.Yes-Deletable.Yes].approved.txt b/legacy_v2_tests/test_deploy_scenarios.approvals/test_deploy_with_update[OnUpdate.UpdateApp-Updatable.Yes-Deletable.Yes].approved.txt similarity index 100% rename from tests/test_deploy_scenarios.approvals/test_deploy_with_update[OnUpdate.UpdateApp-Updatable.Yes-Deletable.Yes].approved.txt rename to legacy_v2_tests/test_deploy_scenarios.approvals/test_deploy_with_update[OnUpdate.UpdateApp-Updatable.Yes-Deletable.Yes].approved.txt diff --git a/tests/test_deploy_scenarios.approvals/test_deploy_with_update_append.approved.txt b/legacy_v2_tests/test_deploy_scenarios.approvals/test_deploy_with_update_append.approved.txt similarity index 100% rename from tests/test_deploy_scenarios.approvals/test_deploy_with_update_append.approved.txt rename to legacy_v2_tests/test_deploy_scenarios.approvals/test_deploy_with_update_append.approved.txt diff --git a/tests/test_deploy_scenarios.py b/legacy_v2_tests/test_deploy_scenarios.py similarity index 98% rename from tests/test_deploy_scenarios.py rename to legacy_v2_tests/test_deploy_scenarios.py index d2740876..309fe4a3 100644 --- a/tests/test_deploy_scenarios.py +++ b/legacy_v2_tests/test_deploy_scenarios.py @@ -20,7 +20,7 @@ get_localnet_default_account, ) -from tests.conftest import check_output_stability, get_specs, get_unique_name, read_spec +from legacy_v2_tests.conftest import check_output_stability, get_specs, get_unique_name, read_spec logger = logging.getLogger(__name__) @@ -29,7 +29,7 @@ # If you need to run a test without debug mode, you can reference this mock within the test and disable it explicitly. @pytest.fixture(autouse=True) def mock_config(tmp_path_factory: pytest.TempPathFactory) -> Generator[Mock, None, None]: - with patch("algokit_utils.application_client.config", new_callable=Mock) as mock_config: + with patch("algokit_utils._legacy_v2.application_client.config", new_callable=Mock) as mock_config: mock_config.debug = True cwd = tmp_path_factory.mktemp("cwd") mock_config.project_root = cwd diff --git a/tests/test_dispenser_api_client.py b/legacy_v2_tests/test_dispenser_api_client.py similarity index 100% rename from tests/test_dispenser_api_client.py rename to legacy_v2_tests/test_dispenser_api_client.py diff --git a/tests/test_network_clients.py b/legacy_v2_tests/test_network_clients.py similarity index 100% rename from tests/test_network_clients.py rename to legacy_v2_tests/test_network_clients.py diff --git a/tests/test_transfer.approvals/test_transfer_algo_max_fee_fails.approved.txt b/legacy_v2_tests/test_transfer.approvals/test_transfer_algo_max_fee_fails.approved.txt similarity index 100% rename from tests/test_transfer.approvals/test_transfer_algo_max_fee_fails.approved.txt rename to legacy_v2_tests/test_transfer.approvals/test_transfer_algo_max_fee_fails.approved.txt diff --git a/tests/test_transfer.approvals/test_transfer_asset_max_fee_fails.approved.txt b/legacy_v2_tests/test_transfer.approvals/test_transfer_asset_max_fee_fails.approved.txt similarity index 100% rename from tests/test_transfer.approvals/test_transfer_asset_max_fee_fails.approved.txt rename to legacy_v2_tests/test_transfer.approvals/test_transfer_asset_max_fee_fails.approved.txt diff --git a/tests/test_transfer.py b/legacy_v2_tests/test_transfer.py similarity index 98% rename from tests/test_transfer.py rename to legacy_v2_tests/test_transfer.py index 7e13cdfb..8253a5eb 100644 --- a/tests/test_transfer.py +++ b/legacy_v2_tests/test_transfer.py @@ -24,8 +24,8 @@ from algosdk.util import algos_to_microalgos from pytest_httpx import HTTPXMock -from tests.conftest import assure_funds, check_output_stability, generate_test_asset, get_unique_name -from tests.test_network_clients import DEFAULT_TOKEN +from legacy_v2_tests.conftest import assure_funds, check_output_stability, generate_test_asset, get_unique_name +from legacy_v2_tests.test_network_clients import DEFAULT_TOKEN if TYPE_CHECKING: from algosdk.kmd import KMDClient diff --git a/poetry.lock b/poetry.lock index eb3dedd7..3544afa0 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. [[package]] name = "alabaster" @@ -2072,6 +2072,26 @@ files = [ {file = "semver-2.13.0.tar.gz", hash = "sha256:fa0fe2722ee1c3f57eac478820c3a5ae2f624af8264cbdf9000c980ff7f75e3f"}, ] +[[package]] +name = "setuptools" +version = "75.2.0" +description = "Easily download, build, install, upgrade, and uninstall Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "setuptools-75.2.0-py3-none-any.whl", hash = "sha256:a7fcb66f68b4d9e8e66b42f9876150a3371558f98fa32222ffaa5bced76406f8"}, + {file = "setuptools-75.2.0.tar.gz", hash = "sha256:753bb6ebf1f465a1912e19ed1d41f403a79173a9acf66a42e7e6aec45c3c16ec"}, +] + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)", "ruff (>=0.5.2)"] +core = ["importlib-metadata (>=6)", "importlib-resources (>=5.10.2)", "jaraco.collections", "jaraco.functools", "jaraco.text (>=3.7)", "more-itertools", "more-itertools (>=8.8)", "packaging", "packaging (>=24)", "platformdirs (>=2.6.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] +type = ["importlib-metadata (>=7.0.2)", "jaraco.develop (>=7.21)", "mypy (==1.11.*)", "pytest-mypy"] + [[package]] name = "six" version = "1.16.0" @@ -2636,4 +2656,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "59127574db0011d8eb6e5a2d55be3048e9cb4a68e34c9c3e5f4a836d488b7318" +content-hash = "66e85df44cca4d3edccb50f730dfb4e9dccf93582e78fa0074dc9b47baa925e2" diff --git a/pyproject.toml b/pyproject.toml index f82ba2d8..4e3a99a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ pytest-httpx = "^0.21.3" pytest-xdist = "^3.4.0" sphinx-markdown-builder = "^0.6.6" linkify-it-py = "^2.0.3" +setuptools = "^75.2.0" [build-system] requires = ["poetry-core"] diff --git a/src/algokit_utils/__init__.py b/src/algokit_utils/__init__.py index 02a5e341..77959758 100644 --- a/src/algokit_utils/__init__.py +++ b/src/algokit_utils/__init__.py @@ -1,7 +1,7 @@ from algokit_utils._debugging import PersistSourceMapInput, persist_sourcemaps, simulate_and_persist_response -from algokit_utils._ensure_funded import EnsureBalanceParameters, EnsureFundedResponse, ensure_funded -from algokit_utils._transfer import TransferAssetParameters, TransferParameters, transfer, transfer_asset -from algokit_utils.account import ( +from algokit_utils._legacy_v2._ensure_funded import EnsureBalanceParameters, EnsureFundedResponse, ensure_funded +from algokit_utils._legacy_v2._transfer import TransferAssetParameters, TransferParameters, transfer, transfer_asset +from algokit_utils._legacy_v2.account import ( create_kmd_wallet_account, get_account, get_account_from_mnemonic, @@ -10,14 +10,14 @@ get_localnet_default_account, get_or_create_kmd_wallet_account, ) -from algokit_utils.application_client import ( +from algokit_utils._legacy_v2.application_client import ( ApplicationClient, execute_atc_with_logic_error, get_next_version, get_sender_from_signer, num_extra_program_pages, ) -from algokit_utils.application_specification import ( +from algokit_utils._legacy_v2.application_specification import ( ApplicationSpecification, AppSpecStateDict, CallConfig, @@ -27,9 +27,9 @@ MethodHints, OnCompleteActionName, ) -from algokit_utils.asset import opt_in, opt_out -from algokit_utils.common import Program -from algokit_utils.deploy import ( +from algokit_utils._legacy_v2.asset import opt_in, opt_out +from algokit_utils._legacy_v2.common import Program +from algokit_utils._legacy_v2.deploy import ( DELETABLE_TEMPLATE_NAME, NOTE_PREFIX, UPDATABLE_TEMPLATE_NAME, @@ -56,32 +56,24 @@ get_creator_apps, replace_template_variables, ) -from algokit_utils.dispenser_api import ( - DISPENSER_ACCESS_TOKEN_KEY, - DISPENSER_REQUEST_TIMEOUT, - DispenserFundResponse, - DispenserLimitResponse, - TestNetDispenserApiClient, -) -from algokit_utils.logic_error import LogicError -from algokit_utils.models import ( +from algokit_utils._legacy_v2.logic_error import LogicError +from algokit_utils._legacy_v2.models import ( ABIArgsDict, ABIMethod, ABITransactionResponse, Account, - CommonCallParameters, # noqa: F401 - CommonCallParametersDict, # noqa: F401 + CommonCallParameters, + CommonCallParametersDict, CreateCallParameters, CreateCallParametersDict, CreateTransactionParameters, OnCompleteCallParameters, OnCompleteCallParametersDict, - RawTransactionParameters, # noqa: F401 TransactionParameters, TransactionParametersDict, TransactionResponse, ) -from algokit_utils.network_clients import ( +from algokit_utils._legacy_v2.network_clients import ( AlgoClientConfig, get_algod_client, get_algonode_config, @@ -92,8 +84,16 @@ is_mainnet, is_testnet, ) +from algokit_utils.clients.dispenser_api_client import ( + DISPENSER_ACCESS_TOKEN_KEY, + DISPENSER_REQUEST_TIMEOUT, + DispenserFundResponse, + DispenserLimitResponse, + TestNetDispenserApiClient, +) __all__ = [ + # ==== LEGACY V2 EXPORTS BEGIN ==== "create_kmd_wallet_account", "get_account_from_mnemonic", "get_or_create_kmd_wallet_account", @@ -120,6 +120,8 @@ "CreateCallParameters", "CreateCallParametersDict", "CreateTransactionParameters", + "CommonCallParameters", + "CommonCallParametersDict", "DeployCallArgs", "DeployCreateCallArgs", "DeployCallArgsDict", @@ -179,4 +181,5 @@ "persist_sourcemaps", "PersistSourceMapInput", "simulate_and_persist_response", + # ==== LEGACY V2 EXPORTS END ==== ] diff --git a/src/algokit_utils/_debugging.py b/src/algokit_utils/_debugging.py index 5563a08e..e8c0ef52 100644 --- a/src/algokit_utils/_debugging.py +++ b/src/algokit_utils/_debugging.py @@ -14,7 +14,7 @@ from algosdk.encoding import checksum from algosdk.v2client.models import SimulateRequest, SimulateRequestTransactionGroup, SimulateTraceConfig -from algokit_utils.common import Program +from algokit_utils._legacy_v2.common import Program if typing.TYPE_CHECKING: from algosdk.v2client.algod import AlgodClient diff --git a/src/algokit_utils/_legacy_v2/__init__.py b/src/algokit_utils/_legacy_v2/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/algokit_utils/_ensure_funded.py b/src/algokit_utils/_legacy_v2/_ensure_funded.py similarity index 95% rename from src/algokit_utils/_ensure_funded.py rename to src/algokit_utils/_legacy_v2/_ensure_funded.py index b80734e4..23c87860 100644 --- a/src/algokit_utils/_ensure_funded.py +++ b/src/algokit_utils/_legacy_v2/_ensure_funded.py @@ -5,14 +5,14 @@ from algosdk.transaction import SuggestedParams from algosdk.v2client.algod import AlgodClient -from algokit_utils._transfer import TransferParameters, transfer -from algokit_utils.account import get_dispenser_account -from algokit_utils.dispenser_api import ( +from algokit_utils._legacy_v2._transfer import TransferParameters, transfer +from algokit_utils._legacy_v2.account import get_dispenser_account +from algokit_utils._legacy_v2.models import Account +from algokit_utils._legacy_v2.network_clients import is_testnet +from algokit_utils.clients.dispenser_api_client import ( DispenserAssetName, TestNetDispenserApiClient, ) -from algokit_utils.models import Account -from algokit_utils.network_clients import is_testnet @dataclass(kw_only=True) diff --git a/src/algokit_utils/_transfer.py b/src/algokit_utils/_legacy_v2/_transfer.py similarity index 99% rename from src/algokit_utils/_transfer.py rename to src/algokit_utils/_legacy_v2/_transfer.py index 0103b172..baca5b2b 100644 --- a/src/algokit_utils/_transfer.py +++ b/src/algokit_utils/_legacy_v2/_transfer.py @@ -7,7 +7,7 @@ from algosdk.atomic_transaction_composer import AccountTransactionSigner from algosdk.transaction import AssetTransferTxn, PaymentTxn, SuggestedParams -from algokit_utils.models import Account +from algokit_utils._legacy_v2.models import Account if TYPE_CHECKING: from algosdk.v2client.algod import AlgodClient diff --git a/src/algokit_utils/_legacy_v2/account.py b/src/algokit_utils/_legacy_v2/account.py new file mode 100644 index 00000000..819a448f --- /dev/null +++ b/src/algokit_utils/_legacy_v2/account.py @@ -0,0 +1,183 @@ +import logging +import os +from typing import TYPE_CHECKING, Any + +from algosdk.account import address_from_private_key +from algosdk.mnemonic import from_private_key, to_private_key +from algosdk.util import algos_to_microalgos + +from algokit_utils._legacy_v2._transfer import TransferParameters, transfer +from algokit_utils._legacy_v2.models import Account +from algokit_utils._legacy_v2.network_clients import get_kmd_client_from_algod_client, is_localnet + +if TYPE_CHECKING: + from collections.abc import Callable + + from algosdk.kmd import KMDClient + from algosdk.v2client.algod import AlgodClient + +__all__ = [ + "create_kmd_wallet_account", + "get_account", + "get_account_from_mnemonic", + "get_dispenser_account", + "get_kmd_wallet_account", + "get_localnet_default_account", + "get_or_create_kmd_wallet_account", +] + +logger = logging.getLogger(__name__) +_DEFAULT_ACCOUNT_MINIMUM_BALANCE = 1_000_000_000 + + +def get_account_from_mnemonic(mnemonic: str) -> Account: + """Convert a mnemonic (25 word passphrase) into an Account""" + private_key = to_private_key(mnemonic) # type: ignore[no-untyped-call] + address = address_from_private_key(private_key) # type: ignore[no-untyped-call] + return Account(private_key=private_key, address=address) + + +def create_kmd_wallet_account(kmd_client: "KMDClient", name: str) -> Account: + """Creates a wallet with specified name""" + wallet_id = kmd_client.create_wallet(name, "")["id"] + wallet_handle = kmd_client.init_wallet_handle(wallet_id, "") + kmd_client.generate_key(wallet_handle) + + key_ids: list[str] = kmd_client.list_keys(wallet_handle) + account_key = key_ids[0] + + private_account_key = kmd_client.export_key(wallet_handle, "", account_key) + return get_account_from_mnemonic(from_private_key(private_account_key)) # type: ignore[no-untyped-call] + + +def get_or_create_kmd_wallet_account( + client: "AlgodClient", name: str, fund_with_algos: float = 1000, kmd_client: "KMDClient | None" = None +) -> Account: + """Returns a wallet with specified name, or creates one if not found""" + kmd_client = kmd_client or get_kmd_client_from_algod_client(client) + account = get_kmd_wallet_account(client, kmd_client, name) + + if account: + 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 LocalNet with name '{name}', but no funds in the account.") + else: + account = create_kmd_wallet_account(kmd_client, name) + + logger.debug( + f"Couldn't find existing account in LocalNet 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} ALGOs") + + 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 + + +def _is_default_account(account: dict[str, Any]) -> bool: + return bool(account["status"] != "Offline" and account["amount"] > _DEFAULT_ACCOUNT_MINIMUM_BALANCE) + + +def get_localnet_default_account(client: "AlgodClient") -> Account: + """Returns the default Account in a LocalNet instance""" + if not is_localnet(client): + raise Exception("Can't get a default account from non LocalNet network") + + account = get_kmd_wallet_account( + client, get_kmd_client_from_algod_client(client), "unencrypted-default-wallet", _is_default_account + ) + assert account + return account + + +def get_dispenser_account(client: "AlgodClient") -> Account: + """Returns an Account based on DISPENSER_MNENOMIC environment variable or the default account on LocalNet""" + if is_localnet(client): + return get_localnet_default_account(client) + return get_account(client, "DISPENSER") + + +def get_kmd_wallet_account( + client: "AlgodClient", + kmd_client: "KMDClient", + name: str, + predicate: "Callable[[dict[str, Any]], bool] | None" = None, +) -> Account | None: + """Returns wallet matching specified name and predicate or None if not found""" + wallets: list[dict] = kmd_client.list_wallets() + + wallet = next((w for w in wallets if w["name"] == name), None) + if wallet is None: + return None + + wallet_id = wallet["id"] + wallet_handle = kmd_client.init_wallet_handle(wallet_id, "") + key_ids: list[str] = kmd_client.list_keys(wallet_handle) + matched_account_key = None + if predicate: + for key in key_ids: + account = client.account_info(key) + assert isinstance(account, dict) + if predicate(account): + matched_account_key = key + else: + matched_account_key = next(key_ids.__iter__(), None) + + if not matched_account_key: + return None + + private_account_key = kmd_client.export_key(wallet_handle, "", matched_account_key) + return get_account_from_mnemonic(from_private_key(private_account_key)) # type: ignore[no-untyped-call] + + +def get_account( + client: "AlgodClient", name: str, fund_with_algos: float = 1000, kmd_client: "KMDClient | None" = None +) -> Account: + """Returns an Algorand account with private key loaded by convention based on the given name identifier. + + # Convention + + **Non-LocalNet:** will load `os.environ[f"{name}_MNEMONIC"]` as a mnemonic secret + Be careful how the mnemonic is handled, never commit it into source control and ideally load it via a + secret storage service rather than the file system. + + **LocalNet:** will load the account from a KMD wallet called {name} and if that wallet doesn't exist it will + create it and fund the account for you + + This allows you to write code that will work seamlessly in production and local development (LocalNet) without + manual config locally (including when you reset the LocalNet). + + # Example + If you have a mnemonic secret loaded into `os.environ["ACCOUNT_MNEMONIC"]` then you can call the following to get + that private key loaded into an account object: + ```python + account = get_account('ACCOUNT', algod) + ``` + + If that code runs against LocalNet then a wallet called 'ACCOUNT' will automatically be created with an account + that is automatically funded with 1000 (default) ALGOs from the default LocalNet dispenser. + """ + + mnemonic_key = f"{name.upper()}_MNEMONIC" + mnemonic = os.getenv(mnemonic_key) + if mnemonic: + return get_account_from_mnemonic(mnemonic) + + if is_localnet(client): + account = get_or_create_kmd_wallet_account(client, name, fund_with_algos, kmd_client) + os.environ[mnemonic_key] = from_private_key(account.private_key) # type: ignore[no-untyped-call] + return account + + raise Exception(f"Missing environment variable '{mnemonic_key}' when looking for account '{name}'") diff --git a/src/algokit_utils/_legacy_v2/application_client.py b/src/algokit_utils/_legacy_v2/application_client.py new file mode 100644 index 00000000..32851fa4 --- /dev/null +++ b/src/algokit_utils/_legacy_v2/application_client.py @@ -0,0 +1,1449 @@ +import base64 +import copy +import json +import logging +import re +import typing +from math import ceil +from pathlib import Path +from typing import Any, Literal, cast, overload + +import algosdk +from algosdk import transaction +from algosdk.abi import ABIType, Method, Returns +from algosdk.account import address_from_private_key +from algosdk.atomic_transaction_composer import ( + ABI_RETURN_HASH, + ABIResult, + AccountTransactionSigner, + AtomicTransactionComposer, + AtomicTransactionResponse, + LogicSigTransactionSigner, + MultisigTransactionSigner, + SimulateAtomicTransactionResponse, + TransactionSigner, + TransactionWithSigner, +) +from algosdk.constants import APP_PAGE_MAX_SIZE +from algosdk.logic import get_application_address +from algosdk.source_map import SourceMap + +import algokit_utils._legacy_v2.application_specification as au_spec +import algokit_utils._legacy_v2.deploy as au_deploy +from algokit_utils._debugging import ( + PersistSourceMapInput, + persist_sourcemaps, + simulate_and_persist_response, + simulate_response, +) +from algokit_utils._legacy_v2.common import Program +from algokit_utils._legacy_v2.logic_error import LogicError, parse_logic_error +from algokit_utils._legacy_v2.models import ( + ABIArgsDict, + ABIArgType, + ABIMethod, + ABITransactionResponse, + Account, + CreateCallParameters, + CreateCallParametersDict, + OnCompleteCallParameters, + OnCompleteCallParametersDict, + SimulationTrace, + TransactionParameters, + TransactionParametersDict, + TransactionResponse, +) +from algokit_utils.config import config + +if typing.TYPE_CHECKING: + from algosdk.v2client.algod import AlgodClient + from algosdk.v2client.indexer import IndexerClient + + +logger = logging.getLogger(__name__) + + +"""A dictionary `dict[str, Any]` representing ABI argument names and values""" + +__all__ = [ + "ApplicationClient", + "execute_atc_with_logic_error", + "get_next_version", + "get_sender_from_signer", + "num_extra_program_pages", +] + +"""Alias for {py:class}`pyteal.ABIReturnSubroutine`, {py:class}`algosdk.abi.method.Method` or a {py:class}`str` +representing an ABI method name or signature""" + + +def num_extra_program_pages(approval: bytes, clear: bytes) -> int: + """Calculate minimum number of extra_pages required for provided approval and clear programs""" + + return ceil(((len(approval) + len(clear)) - APP_PAGE_MAX_SIZE) / APP_PAGE_MAX_SIZE) + + +class ApplicationClient: + """A class that wraps an ARC-0032 app spec and provides high productivity methods to deploy and call the app""" + + @overload + def __init__( + self, + algod_client: "AlgodClient", + app_spec: au_spec.ApplicationSpecification | Path, + *, + app_id: int = 0, + signer: TransactionSigner | Account | None = None, + sender: str | None = None, + suggested_params: transaction.SuggestedParams | None = None, + template_values: au_deploy.TemplateValueMapping | None = None, + ): ... + + @overload + def __init__( + self, + algod_client: "AlgodClient", + app_spec: au_spec.ApplicationSpecification | Path, + *, + creator: str | Account, + indexer_client: "IndexerClient | 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.TemplateValueMapping | None = None, + app_name: str | None = None, + ): ... + + def __init__( # noqa: PLR0913 + self, + algod_client: "AlgodClient", + app_spec: au_spec.ApplicationSpecification | Path, + *, + app_id: int = 0, + creator: str | Account | None = None, + indexer_client: "IndexerClient | 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.TemplateValueMapping | None = None, + app_name: str | None = None, + ): + """ApplicationClient can be created with an app_id to interact with an existing application, alternatively + it can be created with a creator and indexer_client specified to find existing applications by name and creator. + + :param AlgodClient algod_client: AlgoSDK algod client + :param ApplicationSpecification | Path app_spec: An Application Specification or the path to one + :param int app_id: The app_id of an existing application, to instead find the application by creator and name + use the creator and indexer_client parameters + :param str | Account creator: The address or Account of the app creator to resolve the app_id + :param IndexerClient indexer_client: AlgoSDK indexer client, only required if deploying or finding app_id by + creator and app name + :param AppLookup existing_deployments: + :param TransactionSigner | Account signer: Account or signer to use to sign transactions, if not specified and + creator was passed as an Account will use that. + :param str sender: Address to use as the sender for all transactions, will use the address associated with the + signer if not specified. + :param TemplateValueMapping template_values: Values to use for TMPL_* template variables, dictionary keys should + *NOT* include the TMPL_ prefix + :param str | None app_name: Name of application to use when deploying, defaults to name defined on the + Application Specification + """ + self.algod_client = algod_client + self.app_spec = ( + au_spec.ApplicationSpecification.from_json(app_spec.read_text()) if isinstance(app_spec, Path) else app_spec + ) + self._app_name = app_name + self._approval_program: Program | None = None + self._approval_source_map: SourceMap | None = None + self._clear_program: Program | None = None + + self.template_values: au_deploy.TemplateValueMapping = template_values or {} + self.existing_deployments = existing_deployments + self._indexer_client = indexer_client + if creator is not None: + if not self.existing_deployments and not self._indexer_client: + raise Exception( + "If using the creator parameter either existing_deployments or indexer_client must also be provided" + ) + self._creator: str | None = creator.address if isinstance(creator, Account) else creator + if self.existing_deployments and self.existing_deployments.creator != self._creator: + raise Exception( + "Attempt to create application client with invalid existing_deployments against" + f"a different creator ({self.existing_deployments.creator} instead of " + f"expected creator {self._creator}" + ) + self.app_id = 0 + else: + self.app_id = app_id + self._creator = None + + self.signer: TransactionSigner | None + if signer: + self.signer = ( + signer if isinstance(signer, TransactionSigner) else AccountTransactionSigner(signer.private_key) + ) + elif isinstance(creator, Account): + self.signer = AccountTransactionSigner(creator.private_key) + else: + self.signer = None + + self.sender = sender + self.suggested_params = suggested_params + + @property + def app_name(self) -> str: + return self._app_name or self.app_spec.contract.name + + @app_name.setter + def app_name(self, value: str) -> None: + self._app_name = value + + @property + def app_address(self) -> str: + return get_application_address(self.app_id) + + @property + def approval(self) -> Program | None: + return self._approval_program + + @property + def approval_source_map(self) -> SourceMap | None: + if self._approval_source_map: + return self._approval_source_map + if self._approval_program: + return self._approval_program.source_map + return None + + @approval_source_map.setter + def approval_source_map(self, value: SourceMap) -> None: + self._approval_source_map = value + + @property + 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": + """Creates a copy of this ApplicationClient, using the new signer, sender and app_id values if provided. + Will also substitute provided template_values into the associated app_spec in the copy""" + new_client: ApplicationClient = copy.copy(self) + new_client._prepare( # noqa: SLF001 + new_client, signer=signer, sender=sender, app_id=app_id, template_values=template_values + ) + return new_client + + def _prepare( # noqa: PLR0913 + 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 + target.signer, target.sender = target.get_signer_sender( + AccountTransactionSigner(signer.private_key) if isinstance(signer, Account) else signer, sender + ) + target.template_values = {**self.template_values, **(template_values or {})} + + def deploy( # noqa: PLR0913 + self, + version: str | None = None, + *, + signer: TransactionSigner | None = None, + sender: str | None = None, + allow_update: bool | None = None, + allow_delete: bool | None = None, + on_update: au_deploy.OnUpdate = au_deploy.OnUpdate.Fail, + on_schema_break: au_deploy.OnSchemaBreak = au_deploy.OnSchemaBreak.Fail, + template_values: au_deploy.TemplateValueMapping | None = None, + create_args: au_deploy.ABICreateCallArgs + | au_deploy.ABICreateCallArgsDict + | au_deploy.DeployCreateCallArgs + | None = None, + update_args: au_deploy.ABICallArgs | au_deploy.ABICallArgsDict | au_deploy.DeployCallArgs | None = None, + delete_args: au_deploy.ABICallArgs | au_deploy.ABICallArgsDict | au_deploy.DeployCallArgs | None = None, + ) -> au_deploy.DeployResponse: + """Deploy an application and update client to reference it. + + Idempotently deploy (create, update/delete if changed) an app against the given name via the given creator + account, including deploy-time template placeholder substitutions. + To understand the architecture decisions behind this functionality please see + + + ```{note} + If there is a breaking state schema change to an existing app (and `on_schema_break` is set to + 'ReplaceApp' the existing app will be deleted and re-created. + ``` + + ```{note} + If there is an update (different TEAL code) to an existing app (and `on_update` is set to 'ReplaceApp') + the existing app will be deleted and re-created. + ``` + + :param str version: version to use when creating or updating app, if None version will be auto incremented + :param algosdk.atomic_transaction_composer.TransactionSigner signer: signer to use when deploying app + , if None uses self.signer + :param str sender: sender address to use when deploying app, if None uses self.sender + :param bool allow_delete: Used to set the `TMPL_DELETABLE` template variable to conditionally control if an app + can be deleted + :param bool allow_update: Used to set the `TMPL_UPDATABLE` template variable to conditionally control if an app + can be updated + :param OnUpdate on_update: Determines what action to take if an application update is required + :param OnSchemaBreak on_schema_break: Determines what action to take if an application schema requirements + has increased beyond the current allocation + :param dict[str, int|str|bytes] template_values: Values to use for `TMPL_*` template variables, dictionary keys + should *NOT* include the TMPL_ prefix + :param ABICreateCallArgs create_args: Arguments used when creating an application + :param ABICallArgs | ABICallArgsDict update_args: Arguments used when updating an application + :param ABICallArgs | ABICallArgsDict delete_args: Arguments used when deleting an application + :return DeployResponse: details action taken and relevant transactions + :raises DeploymentError: If the deployment failed + """ + # check inputs + if self.app_id: + raise au_deploy.DeploymentFailedError( + f"Attempt to deploy app which already has an app index of {self.app_id}" + ) + try: + resolved_signer, resolved_sender = self.resolve_signer_sender(signer, sender) + except ValueError as ex: + raise au_deploy.DeploymentFailedError(f"{ex}, unable to deploy app") from None + if not self._creator: + raise au_deploy.DeploymentFailedError("No creator provided, unable to deploy app") + if self._creator != resolved_sender: + raise au_deploy.DeploymentFailedError( + f"Attempt to deploy contract with a sender address {resolved_sender} that differs " + f"from the given creator address for this application client: {self._creator}" + ) + + # make a copy and prepare variables + template_values = {**self.template_values, **(template_values or {})} + au_deploy.add_deploy_template_variables(template_values, allow_update=allow_update, allow_delete=allow_delete) + + existing_app_metadata_or_reference = self._load_app_reference() + + self._approval_program, self._clear_program = substitute_template_and_compile( + self.algod_client, self.app_spec, template_values + ) + + if config.debug and config.project_root: + persist_sourcemaps( + sources=[ + PersistSourceMapInput( + compiled_teal=self._approval_program, app_name=self.app_name, file_name="approval.teal" + ), + PersistSourceMapInput( + compiled_teal=self._clear_program, app_name=self.app_name, file_name="clear.teal" + ), + ], + project_root=config.project_root, + client=self.algod_client, + with_sources=True, + ) + + deployer = au_deploy.Deployer( + app_client=self, + creator=self._creator, + signer=resolved_signer, + sender=resolved_sender, + new_app_metadata=self._get_app_deploy_metadata(version, allow_update, allow_delete), + existing_app_metadata_or_reference=existing_app_metadata_or_reference, + on_update=on_update, + on_schema_break=on_schema_break, + create_args=create_args, + update_args=update_args, + delete_args=delete_args, + ) + + return deployer.deploy() + + def compose_create( + self, + atc: AtomicTransactionComposer, + /, + call_abi_method: ABIMethod | 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_transaction_parameters(transaction_parameters) + + extra_pages = transaction_parameters.extra_pages or num_extra_program_pages( + approval_program.raw_binary, clear_program.raw_binary + ) + + self.add_method_call( + atc, + app_id=0, + 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, + ) + + @overload + def create( + self, + call_abi_method: Literal[False], + transaction_parameters: CreateCallParameters | CreateCallParametersDict | None = ..., + ) -> TransactionResponse: ... + + @overload + def create( + self, + call_abi_method: ABIMethod | Literal[True], + transaction_parameters: CreateCallParameters | CreateCallParametersDict | None = ..., + **abi_kwargs: ABIArgType, + ) -> ABITransactionResponse: ... + + @overload + def create( + self, + call_abi_method: ABIMethod | bool | None = ..., + transaction_parameters: CreateCallParameters | CreateCallParametersDict | None = ..., + **abi_kwargs: ABIArgType, + ) -> TransactionResponse | ABITransactionResponse: ... + + def create( + self, + call_abi_method: ABIMethod | 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.compose_create( + atc, + call_abi_method, + transaction_parameters, + **abi_kwargs, + ) + create_result = self._execute_atc_tr(atc) + self.app_id = au_deploy.get_app_id_from_tx_id(self.algod_client, create_result.tx_id) + return create_result + + def compose_update( + self, + atc: AtomicTransactionComposer, + /, + call_abi_method: ABIMethod | bool | None = None, + transaction_parameters: TransactionParameters | TransactionParametersDict | 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.add_method_call( + atc=atc, + 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, + ) + + @overload + def update( + self, + call_abi_method: ABIMethod | Literal[True], + transaction_parameters: TransactionParameters | TransactionParametersDict | None = ..., + **abi_kwargs: ABIArgType, + ) -> ABITransactionResponse: ... + + @overload + def update( + self, + call_abi_method: Literal[False], + transaction_parameters: TransactionParameters | TransactionParametersDict | None = ..., + ) -> TransactionResponse: ... + + @overload + def update( + self, + call_abi_method: ABIMethod | bool | None = ..., + transaction_parameters: TransactionParameters | TransactionParametersDict | None = ..., + **abi_kwargs: ABIArgType, + ) -> TransactionResponse | ABITransactionResponse: ... + + def update( + self, + call_abi_method: ABIMethod | bool | None = None, + transaction_parameters: TransactionParameters | TransactionParametersDict | None = None, + **abi_kwargs: ABIArgType, + ) -> TransactionResponse | ABITransactionResponse: + """Submits a signed transaction with on_complete=UpdateApplication""" + + atc = AtomicTransactionComposer() + self.compose_update( + atc, + call_abi_method, + transaction_parameters=transaction_parameters, + **abi_kwargs, + ) + return self._execute_atc_tr(atc) + + def compose_delete( + self, + atc: AtomicTransactionComposer, + /, + call_abi_method: ABIMethod | bool | None = None, + transaction_parameters: TransactionParameters | TransactionParametersDict | None = None, + **abi_kwargs: ABIArgType, + ) -> None: + """Adds a signed transaction with on_complete=DeleteApplication to atc""" + + self.add_method_call( + atc, + call_abi_method, + abi_args=abi_kwargs, + parameters=transaction_parameters, + on_complete=transaction.OnComplete.DeleteApplicationOC, + ) + + @overload + def delete( + self, + call_abi_method: ABIMethod | Literal[True], + transaction_parameters: TransactionParameters | TransactionParametersDict | None = ..., + **abi_kwargs: ABIArgType, + ) -> ABITransactionResponse: ... + + @overload + def delete( + self, + call_abi_method: Literal[False], + transaction_parameters: TransactionParameters | TransactionParametersDict | None = ..., + ) -> TransactionResponse: ... + + @overload + def delete( + self, + call_abi_method: ABIMethod | bool | None = ..., + transaction_parameters: TransactionParameters | TransactionParametersDict | None = ..., + **abi_kwargs: ABIArgType, + ) -> TransactionResponse | ABITransactionResponse: ... + + def delete( + self, + call_abi_method: ABIMethod | bool | None = None, + transaction_parameters: TransactionParameters | TransactionParametersDict | None = None, + **abi_kwargs: ABIArgType, + ) -> TransactionResponse | ABITransactionResponse: + """Submits a signed transaction with on_complete=DeleteApplication""" + + atc = AtomicTransactionComposer() + self.compose_delete( + atc, + call_abi_method, + transaction_parameters=transaction_parameters, + **abi_kwargs, + ) + return self._execute_atc_tr(atc) + + def compose_call( + self, + atc: AtomicTransactionComposer, + /, + call_abi_method: ABIMethod | bool | None = None, + transaction_parameters: OnCompleteCallParameters | OnCompleteCallParametersDict | None = None, + **abi_kwargs: ABIArgType, + ) -> None: + """Adds a signed transaction with specified parameters to atc""" + _parameters = _convert_transaction_parameters(transaction_parameters) + self.add_method_call( + atc, + abi_method=call_abi_method, + abi_args=abi_kwargs, + parameters=_parameters, + on_complete=_parameters.on_complete or transaction.OnComplete.NoOpOC, + ) + + @overload + def call( + self, + call_abi_method: ABIMethod | Literal[True], + transaction_parameters: OnCompleteCallParameters | OnCompleteCallParametersDict | None = ..., + **abi_kwargs: ABIArgType, + ) -> ABITransactionResponse: ... + + @overload + def call( + self, + call_abi_method: Literal[False], + transaction_parameters: OnCompleteCallParameters | OnCompleteCallParametersDict | None = ..., + ) -> TransactionResponse: ... + + @overload + def call( + self, + call_abi_method: ABIMethod | bool | None = ..., + transaction_parameters: OnCompleteCallParameters | OnCompleteCallParametersDict | None = ..., + **abi_kwargs: ABIArgType, + ) -> TransactionResponse | ABITransactionResponse: ... + + def call( + self, + call_abi_method: ABIMethod | bool | None = None, + transaction_parameters: OnCompleteCallParameters | OnCompleteCallParametersDict | None = None, + **abi_kwargs: ABIArgType, + ) -> TransactionResponse | ABITransactionResponse: + """Submits a signed transaction with specified parameters""" + atc = AtomicTransactionComposer() + _parameters = _convert_transaction_parameters(transaction_parameters) + self.compose_call( + atc, + 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 method: + hints = self._method_hints(method) + if hints and hints.read_only: + if config.debug and config.project_root and config.trace_all: + simulate_and_persist_response( + atc, config.project_root, self.algod_client, config.trace_buffer_size_mb + ) + + return self._simulate_readonly_call(method, atc) + + return self._execute_atc_tr(atc) + + def compose_opt_in( + self, + atc: AtomicTransactionComposer, + /, + call_abi_method: ABIMethod | bool | None = None, + transaction_parameters: TransactionParameters | TransactionParametersDict | None = None, + **abi_kwargs: ABIArgType, + ) -> None: + """Adds a signed transaction with on_complete=OptIn to atc""" + self.add_method_call( + atc, + abi_method=call_abi_method, + abi_args=abi_kwargs, + parameters=transaction_parameters, + on_complete=transaction.OnComplete.OptInOC, + ) + + @overload + def opt_in( + self, + call_abi_method: ABIMethod | Literal[True] = ..., + transaction_parameters: TransactionParameters | TransactionParametersDict | None = None, + **abi_kwargs: ABIArgType, + ) -> ABITransactionResponse: ... + + @overload + def opt_in( + self, + call_abi_method: Literal[False] = ..., + transaction_parameters: TransactionParameters | TransactionParametersDict | None = None, + ) -> TransactionResponse: ... + + @overload + def opt_in( + self, + call_abi_method: ABIMethod | bool | None = ..., + transaction_parameters: TransactionParameters | TransactionParametersDict | None = ..., + **abi_kwargs: ABIArgType, + ) -> TransactionResponse | ABITransactionResponse: ... + + def opt_in( + self, + call_abi_method: ABIMethod | bool | None = None, + transaction_parameters: TransactionParameters | TransactionParametersDict | None = None, + **abi_kwargs: ABIArgType, + ) -> TransactionResponse | ABITransactionResponse: + """Submits a signed transaction with on_complete=OptIn""" + atc = AtomicTransactionComposer() + self.compose_opt_in( + atc, + 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, + /, + call_abi_method: ABIMethod | bool | None = None, + transaction_parameters: TransactionParameters | TransactionParametersDict | None = None, + **abi_kwargs: ABIArgType, + ) -> None: + """Adds a signed transaction with on_complete=CloseOut to ac""" + self.add_method_call( + atc, + abi_method=call_abi_method, + abi_args=abi_kwargs, + parameters=transaction_parameters, + on_complete=transaction.OnComplete.CloseOutOC, + ) + + @overload + def close_out( + self, + call_abi_method: ABIMethod | Literal[True], + transaction_parameters: TransactionParameters | TransactionParametersDict | None = ..., + **abi_kwargs: ABIArgType, + ) -> ABITransactionResponse: ... + + @overload + def close_out( + self, + call_abi_method: Literal[False], + transaction_parameters: TransactionParameters | TransactionParametersDict | None = ..., + ) -> TransactionResponse: ... + + @overload + def close_out( + self, + call_abi_method: ABIMethod | bool | None = ..., + transaction_parameters: TransactionParameters | TransactionParametersDict | None = ..., + **abi_kwargs: ABIArgType, + ) -> TransactionResponse | ABITransactionResponse: ... + + def close_out( + self, + call_abi_method: ABIMethod | bool | None = None, + transaction_parameters: TransactionParameters | TransactionParametersDict | None = None, + **abi_kwargs: ABIArgType, + ) -> TransactionResponse | ABITransactionResponse: + """Submits a signed transaction with on_complete=CloseOut""" + atc = AtomicTransactionComposer() + self.compose_close_out( + atc, + 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, + /, + transaction_parameters: TransactionParameters | TransactionParametersDict | None = None, + app_args: list[bytes] | None = None, + ) -> None: + """Adds a signed transaction with on_complete=ClearState to atc""" + return self.add_method_call( + atc, + parameters=transaction_parameters, + on_complete=transaction.OnComplete.ClearStateOC, + app_args=app_args, + ) + + def clear_state( + self, + transaction_parameters: TransactionParameters | TransactionParametersDict | None = None, + app_args: list[bytes] | None = None, + ) -> TransactionResponse: + """Submits a signed transaction with on_complete=ClearState""" + atc = AtomicTransactionComposer() + self.compose_clear_state( + atc, + 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 associated with 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), + ) + + def get_local_state(self, account: str | None = None, *, raw: bool = False) -> dict[bytes | str, bytes | str | int]: + """Gets the local state info for associated app_id and account/sender""" + + if account is None: + _, account = self.resolve_signer_sender(self.signer, self.sender) + + 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: au_spec.DefaultArgumentDict) -> int | str | bytes: + """Resolves the default value for an ABI method, based on app_spec""" + + def _data_check(value: object) -> int | str | bytes: + if isinstance(value, int | str | bytes): + return value + raise ValueError(f"Unexpected type for constant data: {value}") + + match to_resolve: + case {"source": "constant", "data": data}: + return _data_check(data) + case {"source": "global-state", "data": str() as key}: + global_state = self.get_global_state(raw=True) + return global_state[key.encode()] + case {"source": "local-state", "data": str() as key}: + _, sender = self.resolve_signer_sender(self.signer, self.sender) + acct_state = self.get_local_state(sender, raw=True) + return acct_state[key.encode()] + case {"source": "abi-method", "data": dict() as method_dict}: + method = Method.undictify(method_dict) + response = self.call(method) + assert isinstance(response, ABITransactionResponse) + return _data_check(response.return_value) + + case {"source": source}: + raise ValueError(f"Unrecognized default argument source: {source}") + case _: + raise TypeError("Unable to interpret default argument specification") + + def _get_app_deploy_metadata( + self, version: str | None, allow_update: bool | None, allow_delete: bool | None + ) -> au_deploy.AppDeployMetaData: + updatable = ( + allow_update + if allow_update is not None + 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 au_deploy.get_deploy_control( + self.app_spec, au_deploy.DELETABLE_TEMPLATE_NAME, transaction.OnComplete.DeleteApplicationOC + ) + ) + + app = self._load_app_reference() + + if version is None: + if app.app_id == 0: + version = "v1.0" + else: + assert isinstance(app, au_deploy.AppDeployMetaData) + version = get_next_version(app.version) + return au_deploy.AppDeployMetaData(self.app_name, version, updatable=updatable, deletable=deletable) + + def _check_is_compiled(self) -> tuple[Program, Program]: + if self._approval_program is None or self._clear_program is None: + self._approval_program, self._clear_program = substitute_template_and_compile( + self.algod_client, self.app_spec, self.template_values + ) + + if config.debug and config.project_root: + persist_sourcemaps( + sources=[ + PersistSourceMapInput( + compiled_teal=self._approval_program, app_name=self.app_name, file_name="approval.teal" + ), + PersistSourceMapInput( + compiled_teal=self._clear_program, app_name=self.app_name, file_name="clear.teal" + ), + ], + project_root=config.project_root, + client=self.algod_client, + with_sources=True, + ) + + return self._approval_program, self._clear_program + + def _simulate_readonly_call( + self, method: Method, atc: AtomicTransactionComposer + ) -> ABITransactionResponse | TransactionResponse: + response = simulate_response(atc, self.algod_client) + traces = None + if config.debug: + traces = _create_simulate_traces(response) + if response.failure_message: + raise _try_convert_to_logic_error( + response.failure_message, + self.app_spec.approval_program, + self._get_approval_source_map, + traces, + ) or Exception(f"Simulate failed for readonly method {method.get_signature()}: {response.failure_message}") + + return TransactionResponse.from_atr(response) + + def _load_reference_and_check_app_id(self) -> None: + self._load_app_reference() + self._check_app_id() + + 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 = au_deploy.get_creator_apps(self._indexer_client, self._creator) + + if self.existing_deployments: + app = self.existing_deployments.apps.get(self.app_name) + if app: + if self.app_id == 0: + self.app_id = app.app_id + return app + + return au_deploy.AppReference(self.app_id, self.app_address) + + def _check_app_id(self) -> None: + if self.app_id == 0: + raise Exception( + "ApplicationClient is not associated with an app instance, to resolve either:\n" + "1.) provide an app_id on construction OR\n" + "2.) provide a creator address so an app can be searched for OR\n" + "3.) create an app first using create or deploy methods" + ) + + def _resolve_method( + self, + abi_method: ABIMethod | bool | None, + args: ABIArgsDict | None, + on_complete: transaction.OnComplete, + call_config: au_spec.CallConfig = au_spec.CallConfig.CALL, + ) -> Method | None: + matches: list[Method | None] = [] + match abi_method: + case str() | Method(): # abi method specified + return self._resolve_abi_method(abi_method) + case bool() | None: # find abi method + has_bare_config = ( + 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) + if abi_method is not False: + matches += abi_methods + if has_bare_config and abi_method is not True: + matches += [None] + case _: + return abi_method.method_spec() + + if len(matches) == 1: # exact match + return matches[0] + 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.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.name} with call_config of {call_config.name}" + ) + + def _get_approval_source_map(self) -> SourceMap | None: + if self.approval_source_map: + return self.approval_source_map + + try: + approval, _ = self._check_is_compiled() + except au_deploy.DeploymentFailedError: + return None + return approval.source_map + + def export_source_map(self) -> str | None: + """Export approval source map to JSON, can be later re-imported with `import_source_map`""" + source_map = self._get_approval_source_map() + if source_map: + return json.dumps( + { + "version": source_map.version, + "sources": source_map.sources, + "mappings": source_map.mappings, + } + ) + return None + + def import_source_map(self, source_map_json: str) -> None: + """Import approval source from JSON exported by `export_source_map`""" + source_map = json.loads(source_map_json) + self._approval_source_map = SourceMap(source_map) + + def add_method_call( # noqa: PLR0913 + self, + atc: AtomicTransactionComposer, + abi_method: ABIMethod | bool | None = None, + *, + abi_args: ABIArgsDict | None = None, + app_id: int | None = None, + parameters: TransactionParameters | TransactionParametersDict | 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, + 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 + parameters = _convert_transaction_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 parameters.boxes] + else: + encoded_boxes = 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: + raise Exception(f"ABI arguments specified on a bare call: {', '.join(abi_args)}") + atc.add_transaction( + TransactionWithSigner( + txn=transaction.ApplicationCallTxn( # type: ignore[no-untyped-call] + sender=sender, + sp=sp, + index=app_id, + on_complete=on_complete, + approval_program=approval_program, + clear_program=clear_program, + global_schema=global_schema, + local_schema=local_schema, + extra_pages=extra_pages, + accounts=parameters.accounts, + foreign_apps=parameters.foreign_apps, + foreign_assets=parameters.foreign_assets, + boxes=encoded_boxes, + note=parameters.note, + lease=encoded_lease, + rekey_to=parameters.rekey_to, + app_args=app_args, + ), + signer=signer, + ) + ) + return + # resolve ABI method args + args = self._get_abi_method_args(abi_args, method) + atc.add_method_call( + app_id, + method, + sender, + sp, + signer, + method_args=args, + on_complete=on_complete, + local_schema=local_schema, + global_schema=global_schema, + approval_program=approval_program, + clear_program=clear_program, + extra_pages=extra_pages or 0, + accounts=parameters.accounts, + foreign_apps=parameters.foreign_apps, + foreign_assets=parameters.foreign_assets, + boxes=encoded_boxes, + note=parameters.note.encode("utf-8") if isinstance(parameters.note, str) else parameters.note, + lease=encoded_lease, + rekey_to=parameters.rekey_to, + ) + + def _get_abi_method_args(self, abi_args: ABIArgsDict | None, method: Method) -> list: + args: list = [] + hints = self._method_hints(method) + # copy args so we don't mutate original + abi_args = dict(abi_args or {}) + for method_arg in method.args: + name = method_arg.name + if name in abi_args: + argument = abi_args.pop(name) + if isinstance(argument, dict): + if hints.structs is None or name not in hints.structs: + raise Exception(f"Argument missing struct hint: {name}. Check argument name and type") + + elements = hints.structs[name]["elements"] + + argument_tuple = tuple(argument[field_name] for field_name, field_type in elements) + args.append(argument_tuple) + else: + args.append(argument) + + elif hints.default_arguments is not None and name in hints.default_arguments: + default_arg = hints.default_arguments[name] + if default_arg is not None: + args.append(self.resolve(default_arg)) + else: + raise Exception(f"Unspecified argument: {name}") + if abi_args: + raise Exception(f"Unused arguments specified: {', '.join(abi_args)}") + return args + + def _method_matches( + 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 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) + + # TODO: also match on types? + return method_args == provided_args + + def _find_abi_methods( + self, args: ABIArgsDict | None, on_complete: transaction.OnComplete, call_config: au_spec.CallConfig + ) -> list[Method]: + return [ + method + for method in self.app_spec.contract.methods + if self._method_matches(method, args, on_complete, call_config) + ] + + def _resolve_abi_method(self, method: ABIMethod) -> Method: + if isinstance(method, str): + try: + return next(iter(m for m in self.app_spec.contract.methods if m.get_signature() == method)) + except StopIteration: + pass + return self.app_spec.contract.get_method_by_name(method) + elif hasattr(method, "method_spec"): + return method.method_spec() + else: + return method + + def _method_hints(self, method: Method) -> au_spec.MethodHints: + sig = method.get_signature() + if sig not in self.app_spec.hints: + return au_spec.MethodHints() + return self.app_spec.hints[sig] + + def _execute_atc_tr(self, atc: AtomicTransactionComposer) -> TransactionResponse: + result = self.execute_atc(atc) + return TransactionResponse.from_atr(result) + + def execute_atc(self, atc: AtomicTransactionComposer) -> AtomicTransactionResponse: + return execute_atc_with_logic_error( + atc, + self.algod_client, + approval_program=self.app_spec.approval_program, + approval_source_map=self._get_approval_source_map, + ) + + def get_signer_sender( + self, signer: TransactionSigner | None = None, sender: str | None = None + ) -> tuple[TransactionSigner | None, str | None]: + """Return signer and sender, using default values on client if not specified + + Will use provided values if given, otherwise will fall back to values defined on client. + If no sender is specified then will attempt to obtain sender from signer""" + resolved_signer = signer or self.signer + resolved_sender = sender or get_sender_from_signer(signer) or self.sender or get_sender_from_signer(self.signer) + return resolved_signer, resolved_sender + + def resolve_signer_sender( + self, signer: TransactionSigner | None = None, sender: str | None = None + ) -> tuple[TransactionSigner, str]: + """Return signer and sender, using default values on client if not specified + + Will use provided values if given, otherwise will fall back to values defined on client. + If no sender is specified then will attempt to obtain sender from signer + + :raises ValueError: Raised if a signer or sender is not provided. See `get_signer_sender` + for variant with no exception""" + resolved_signer, resolved_sender = self.get_signer_sender(signer, sender) + if not resolved_signer: + raise ValueError("No signer provided") + if not resolved_sender: + raise ValueError("No sender provided") + return resolved_signer, resolved_sender + + # TODO: remove private implementation, kept in the 1.0.2 release to not impact existing beaker 1.0 installs + _resolve_signer_sender = resolve_signer_sender + + +def substitute_template_and_compile( + algod_client: "AlgodClient", + app_spec: au_spec.ApplicationSpecification, + template_values: au_deploy.TemplateValueMapping, +) -> tuple[Program, Program]: + """Substitutes the provided template_values into app_spec and compiles""" + 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) + + approval_app, clear_app = Program(approval, algod_client), Program(clear, algod_client) + + return approval_app, clear_app + + +def get_next_version(current_version: str) -> str: + """Calculates the next version from `current_version` + + Next version is calculated by finding a semver like + version string and incrementing the lower. This function is used by {py:meth}`ApplicationClient.deploy` when + a version is not specified, and is intended mostly for convenience during local development. + + :params str current_version: An existing version string with a semver like version contained within it, + some valid inputs and incremented outputs: + `1` -> `2` + `1.0` -> `1.1` + `v1.1` -> `v1.2` + `v1.1-beta1` -> `v1.2-beta1` + `v1.2.3.4567` -> `v1.2.3.4568` + `v1.2.3.4567-alpha` -> `v1.2.3.4568-alpha` + :raises DeploymentFailedError: If `current_version` cannot be parsed""" + pattern = re.compile(r"(?P\w*)(?P(?:\d+\.)*\d+)(?P\w*)") + match = pattern.match(current_version) + if match: + version = match.group("version") + new_version = _increment_version(version) + + def replacement(m: re.Match) -> str: + return f"{m.group('prefix')}{new_version}{m.group('suffix')}" + + return re.sub(pattern, replacement, current_version) + raise au_deploy.DeploymentFailedError( + f"Could not auto increment {current_version}, please specify the next version using the version parameter" + ) + + +def _try_convert_to_logic_error( + source_ex: Exception | str, + approval_program: str, + approval_source_map: SourceMap | typing.Callable[[], SourceMap | None] | None = None, + simulate_traces: list[SimulationTrace] | None = None, +) -> Exception | None: + source_ex_str = str(source_ex) + logic_error_data = parse_logic_error(source_ex_str) + if logic_error_data: + return LogicError( + logic_error_str=source_ex_str, + logic_error=source_ex if isinstance(source_ex, Exception) else None, + program=approval_program, + source_map=approval_source_map() if callable(approval_source_map) else approval_source_map, + **logic_error_data, + traces=simulate_traces, + ) + + return None + + +def execute_atc_with_logic_error( + atc: AtomicTransactionComposer, + algod_client: "AlgodClient", + approval_program: str, + wait_rounds: int = 4, + approval_source_map: SourceMap | typing.Callable[[], SourceMap | None] | None = None, +) -> AtomicTransactionResponse: + """Calls {py:meth}`AtomicTransactionComposer.execute` on provided `atc`, but will parse any errors + and raise a {py:class}`LogicError` if possible + + ```{note} + `approval_program` and `approval_source_map` are required to be able to parse any errors into a + {py:class}`LogicError` + ``` + """ + try: + if config.debug and config.project_root and config.trace_all: + simulate_and_persist_response(atc, config.project_root, algod_client, config.trace_buffer_size_mb) + + return atc.execute(algod_client, wait_rounds=wait_rounds) + except Exception as ex: + if config.debug: + simulate = None + if config.project_root and not config.trace_all: + # if trace_all is enabled, we already have the traces executed above + # hence we only need to simulate if trace_all is disabled and + # project_root is set + simulate = simulate_and_persist_response( + atc, config.project_root, algod_client, config.trace_buffer_size_mb + ) + else: + simulate = simulate_response(atc, algod_client) + traces = _create_simulate_traces(simulate) + else: + traces = None + logger.info("An error occurred while executing the transaction.") + logger.info("To see more details, enable debug mode by setting config.debug = True ") + + logic_error = _try_convert_to_logic_error(ex, approval_program, approval_source_map, traces) + if logic_error: + raise logic_error from ex + raise ex + + +def _create_simulate_traces(simulate: SimulateAtomicTransactionResponse) -> list[SimulationTrace]: + traces = [] + if hasattr(simulate, "simulate_response") and hasattr(simulate, "failed_at") and simulate.failed_at: + for txn_group in simulate.simulate_response["txn-groups"]: + app_budget_added = txn_group.get("app-budget-added", None) + app_budget_consumed = txn_group.get("app-budget-consumed", None) + failure_message = txn_group.get("failure-message", None) + txn_result = txn_group.get("txn-results", [{}])[0] + exec_trace = txn_result.get("exec-trace", {}) + traces.append( + SimulationTrace( + app_budget_added=app_budget_added, + app_budget_consumed=app_budget_consumed, + failure_message=failure_message, + exec_trace=exec_trace, + ) + ) + return traces + + +def _convert_transaction_parameters( + args: TransactionParameters | TransactionParametersDict | None, +) -> CreateCallParameters: + _args = args.__dict__ if isinstance(args, TransactionParameters) else (args or {}) + return CreateCallParameters(**_args) + + +def get_sender_from_signer(signer: TransactionSigner | None) -> str | None: + """Returns the associated address of a signer, return None if no address found""" + + if isinstance(signer, AccountTransactionSigner): + sender = address_from_private_key(signer.private_key) # type: ignore[no-untyped-call] + assert isinstance(sender, str) + return sender + elif isinstance(signer, MultisigTransactionSigner): + sender = signer.msig.address() # type: ignore[no-untyped-call] + assert isinstance(sender, str) + return sender + elif isinstance(signer, LogicSigTransactionSigner): + return signer.lsig.address() + return None + + +# TEMPORARY, use SDK one when available +def _parse_result( + methods: dict[int, Method], + txns: list[dict[str, Any]], + txids: list[str], +) -> list[ABIResult]: + method_results = [] + for i, tx_info in enumerate(txns): + raw_value = b"" + return_value = None + decode_error = None + + if i not in methods: + continue + + # Parse log for ABI method return value + try: + if methods[i].returns.type == Returns.VOID: + method_results.append( + ABIResult( + tx_id=txids[i], + raw_value=raw_value, + return_value=return_value, + decode_error=decode_error, + tx_info=tx_info, + method=methods[i], + ) + ) + continue + + logs = tx_info.get("logs", []) + + # Look for the last returned value in the log + if not logs: + raise Exception("No logs") + + result = logs[-1] + # Check that the first four bytes is the hash of "return" + result_bytes = base64.b64decode(result) + if len(result_bytes) < len(ABI_RETURN_HASH) or result_bytes[: len(ABI_RETURN_HASH)] != ABI_RETURN_HASH: + raise Exception("no logs") + + raw_value = result_bytes[4:] + abi_return_type = methods[i].returns.type + if isinstance(abi_return_type, ABIType): + return_value = abi_return_type.decode(raw_value) + else: + return_value = raw_value + + except Exception as e: + decode_error = e + + method_results.append( + ABIResult( + tx_id=txids[i], + raw_value=raw_value, + return_value=return_value, + decode_error=decode_error, + tx_info=tx_info, + method=methods[i], + ) + ) + + return method_results + + +def _increment_version(version: str) -> str: + split = list(map(int, version.split("."))) + split[-1] = split[-1] + 1 + return ".".join(str(x) for x in split) + + +def _str_or_hex(v: bytes) -> str: + decoded: str + try: + decoded = v.decode("utf-8") + except UnicodeDecodeError: + decoded = v.hex() + + return decoded + + +def _decode_state(state: list[dict[str, Any]], *, raw: bool = False) -> dict[str | bytes, bytes | str | int | None]: + decoded_state: dict[str | bytes, bytes | str | int | None] = {} + + for state_value in state: + raw_key = base64.b64decode(state_value["key"]) + + key: str | bytes = raw_key if raw else _str_or_hex(raw_key) + val: str | bytes | int | None + + action = state_value["value"]["action"] if "action" in state_value["value"] else state_value["value"]["type"] + + match action: + case 1: + raw_val = base64.b64decode(state_value["value"]["bytes"]) + val = raw_val if raw else _str_or_hex(raw_val) + case 2: + val = state_value["value"]["uint"] + case 3: + val = None + case _: + raise NotImplementedError + + decoded_state[key] = val + return decoded_state diff --git a/src/algokit_utils/_legacy_v2/application_specification.py b/src/algokit_utils/_legacy_v2/application_specification.py new file mode 100644 index 00000000..392fce8d --- /dev/null +++ b/src/algokit_utils/_legacy_v2/application_specification.py @@ -0,0 +1,206 @@ +import base64 +import dataclasses +import json +from enum import IntFlag +from pathlib import Path +from typing import Any, Literal, TypeAlias, TypedDict + +from algosdk.abi import Contract +from algosdk.abi.method import MethodDict +from algosdk.transaction import StateSchema + +__all__ = [ + "CallConfig", + "DefaultArgumentDict", + "DefaultArgumentType", + "MethodConfigDict", + "OnCompleteActionName", + "MethodHints", + "ApplicationSpecification", + "AppSpecStateDict", +] + + +AppSpecStateDict: TypeAlias = dict[str, dict[str, dict]] +"""Type defining Application Specification state entries""" + + +class CallConfig(IntFlag): + """Describes the type of calls a method can be used for based on {py:class}`algosdk.transaction.OnComplete` type""" + + NEVER = 0 + """Never handle the specified on completion type""" + CALL = 1 + """Only handle the specified on completion type for application calls""" + CREATE = 2 + """Only handle the specified on completion type for application create calls""" + ALL = 3 + """Handle the specified on completion type for both create and normal application calls""" + + +class StructArgDict(TypedDict): + name: str + elements: list[list[str]] + + +OnCompleteActionName: TypeAlias = Literal[ + "no_op", "opt_in", "close_out", "clear_state", "update_application", "delete_application" +] +"""String literals representing on completion transaction types""" +MethodConfigDict: TypeAlias = dict[OnCompleteActionName, CallConfig] +"""Dictionary of `dict[OnCompletionActionName, CallConfig]` representing allowed actions for each on completion type""" +DefaultArgumentType: TypeAlias = Literal["abi-method", "local-state", "global-state", "constant"] +"""Literal values describing the types of default argument sources""" + + +class DefaultArgumentDict(TypedDict): + """ + DefaultArgument is a container for any arguments that may + be resolved prior to calling some target method + """ + + source: DefaultArgumentType + data: int | str | bytes | MethodDict + + +StateDict = TypedDict( # need to use function-form of TypedDict here since "global" is a reserved keyword + "StateDict", {"global": AppSpecStateDict, "local": AppSpecStateDict} +) + + +@dataclasses.dataclass(kw_only=True) +class MethodHints: + """MethodHints provides hints to the caller about how to call the method""" + + #: hint to indicate this method can be called through Dryrun + read_only: bool = False + #: hint to provide names for tuple argument indices + #: method_name=>param_name=>{name:str, elements:[str,str]} + structs: dict[str, StructArgDict] = dataclasses.field(default_factory=dict) + #: defaults + default_arguments: dict[str, DefaultArgumentDict] = dataclasses.field(default_factory=dict) + call_config: MethodConfigDict = dataclasses.field(default_factory=dict) + + def empty(self) -> bool: + return not self.dictify() + + def dictify(self) -> dict[str, Any]: + d: dict[str, Any] = {} + if self.read_only: + d["read_only"] = True + if self.default_arguments: + d["default_arguments"] = self.default_arguments + if self.structs: + d["structs"] = self.structs + if any(v for v in self.call_config.values() if v != CallConfig.NEVER): + d["call_config"] = _encode_method_config(self.call_config) + return d + + @staticmethod + def undictify(data: dict[str, Any]) -> "MethodHints": + return MethodHints( + read_only=data.get("read_only", False), + default_arguments=data.get("default_arguments", {}), + structs=data.get("structs", {}), + call_config=_decode_method_config(data.get("call_config", {})), + ) + + +def _encode_method_config(mc: MethodConfigDict) -> dict[str, str | None]: + return {k: mc[k].name for k in sorted(mc) if mc[k] != CallConfig.NEVER} + + +def _decode_method_config(data: dict[OnCompleteActionName, Any]) -> MethodConfigDict: + return {k: CallConfig[v] for k, v in data.items()} + + +def _encode_source(teal_text: str) -> str: + return base64.b64encode(teal_text.encode()).decode("utf-8") + + +def _decode_source(b64_text: str) -> str: + return base64.b64decode(b64_text).decode("utf-8") + + +def _encode_state_schema(schema: StateSchema) -> dict[str, int]: + return { + "num_byte_slices": schema.num_byte_slices, + "num_uints": schema.num_uints, + } + + +def _decode_state_schema(data: dict[str, int]) -> StateSchema: + return StateSchema( # type: ignore[no-untyped-call] + num_byte_slices=data.get("num_byte_slices", 0), + num_uints=data.get("num_uints", 0), + ) + + +@dataclasses.dataclass(kw_only=True) +class ApplicationSpecification: + """ARC-0032 application specification + + See """ + + approval_program: str + clear_program: str + contract: Contract + hints: dict[str, MethodHints] + schema: StateDict + global_state_schema: StateSchema + local_state_schema: StateSchema + bare_call_config: MethodConfigDict + + def dictify(self) -> dict: + return { + "hints": {k: v.dictify() for k, v in self.hints.items() if not v.empty()}, + "source": { + "approval": _encode_source(self.approval_program), + "clear": _encode_source(self.clear_program), + }, + "state": { + "global": _encode_state_schema(self.global_state_schema), + "local": _encode_state_schema(self.local_state_schema), + }, + "schema": self.schema, + "contract": self.contract.dictify(), + "bare_call_config": _encode_method_config(self.bare_call_config), + } + + def to_json(self) -> str: + return json.dumps(self.dictify(), indent=4) + + @staticmethod + def from_json(application_spec: str) -> "ApplicationSpecification": + json_spec = json.loads(application_spec) + return ApplicationSpecification( + approval_program=_decode_source(json_spec["source"]["approval"]), + clear_program=_decode_source(json_spec["source"]["clear"]), + schema=json_spec["schema"], + global_state_schema=_decode_state_schema(json_spec["state"]["global"]), + local_state_schema=_decode_state_schema(json_spec["state"]["local"]), + contract=Contract.undictify(json_spec["contract"]), + hints={k: MethodHints.undictify(v) for k, v in json_spec["hints"].items()}, + bare_call_config=_decode_method_config(json_spec.get("bare_call_config", {})), + ) + + def export(self, directory: Path | str | None = None) -> None: + """write out the artifacts generated by the application to disk + + Args: + directory(optional): path to the directory where the artifacts should be written + """ + if directory is None: + output_dir = Path.cwd() + else: + output_dir = Path(directory) + output_dir.mkdir(exist_ok=True, parents=True) + + (output_dir / "approval.teal").write_text(self.approval_program) + (output_dir / "clear.teal").write_text(self.clear_program) + (output_dir / "contract.json").write_text(json.dumps(self.contract.dictify(), indent=4)) + (output_dir / "application.json").write_text(self.to_json()) + + +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] diff --git a/src/algokit_utils/_legacy_v2/asset.py b/src/algokit_utils/_legacy_v2/asset.py new file mode 100644 index 00000000..2ef4860f --- /dev/null +++ b/src/algokit_utils/_legacy_v2/asset.py @@ -0,0 +1,168 @@ +import logging +from typing import TYPE_CHECKING + +from algosdk.atomic_transaction_composer import AtomicTransactionComposer, TransactionWithSigner +from algosdk.constants import TX_GROUP_LIMIT +from algosdk.transaction import AssetTransferTxn + +if TYPE_CHECKING: + from algosdk.v2client.algod import AlgodClient + +from enum import Enum, auto + +from algokit_utils._legacy_v2.models import Account + +__all__ = ["opt_in", "opt_out"] +logger = logging.getLogger(__name__) + + +class ValidationType(Enum): + OPTIN = auto() + OPTOUT = auto() + + +def _ensure_account_is_valid(algod_client: "AlgodClient", account: Account) -> None: + try: + algod_client.account_info(account.address) + except Exception as err: + error_message = f"Account address{account.address} does not exist" + logger.debug(error_message) + raise err + + +def _ensure_asset_balance_conditions( + algod_client: "AlgodClient", account: Account, asset_ids: list, validation_type: ValidationType +) -> None: + invalid_asset_ids = [] + account_info = algod_client.account_info(account.address) + account_assets = account_info.get("assets", []) # type: ignore # noqa: PGH003 + for asset_id in asset_ids: + asset_exists_in_account_info = any(asset["asset-id"] == asset_id for asset in account_assets) + if validation_type == ValidationType.OPTIN: + if asset_exists_in_account_info: + logger.debug(f"Asset {asset_id} is already opted in for account {account.address}") + invalid_asset_ids.append(asset_id) + + elif validation_type == ValidationType.OPTOUT: + if not account_assets or not asset_exists_in_account_info: + logger.debug(f"Account {account.address} does not have asset {asset_id}") + invalid_asset_ids.append(asset_id) + else: + asset_balance = next((asset["amount"] for asset in account_assets if asset["asset-id"] == asset_id), 0) + if asset_balance != 0: + logger.debug(f"Asset {asset_id} balance is not zero") + invalid_asset_ids.append(asset_id) + + if len(invalid_asset_ids) > 0: + action = "opted out" if validation_type == ValidationType.OPTOUT else "opted in" + condition_message = ( + "their amount is zero and that the account has" + if validation_type == ValidationType.OPTOUT + else "they are valid and that the account has not" + ) + + error_message = ( + f"Assets {invalid_asset_ids} cannot be {action}. Ensure that " + f"{condition_message} previously opted into them." + ) + raise ValueError(error_message) + + +def opt_in(algod_client: "AlgodClient", account: Account, asset_ids: list[int]) -> dict[int, str]: + """ + Opt-in to a list of assets on the Algorand blockchain. Before an account can receive a specific asset, + it must `opt-in` to receive it. An opt-in transaction places an asset holding of 0 into the account and increases + its minimum balance by [100,000 microAlgos](https://developer.algorand.org/docs/get-details/asa/#assets-overview). + + Args: + algod_client (AlgodClient): An instance of the AlgodClient class from the algosdk library. + account (Account): An instance of the Account class representing the account that wants to opt-in to the assets. + asset_ids (list[int]): A list of integers representing the asset IDs to opt-in to. + Returns: + dict[int, str]: A dictionary where the keys are the asset IDs and the values + are the transaction IDs for opting-in to each asset. + """ + _ensure_account_is_valid(algod_client, account) + _ensure_asset_balance_conditions(algod_client, account, asset_ids, ValidationType.OPTIN) + suggested_params = algod_client.suggested_params() + result = {} + for i in range(0, len(asset_ids), TX_GROUP_LIMIT): + atc = AtomicTransactionComposer() + chunk = asset_ids[i : i + TX_GROUP_LIMIT] + for asset_id in chunk: + asset = algod_client.asset_info(asset_id) + xfer_txn = AssetTransferTxn( + sp=suggested_params, + sender=account.address, + receiver=account.address, + close_assets_to=None, + revocation_target=None, + amt=0, + note=f"opt in asset id ${asset_id}", + index=asset["index"], # type: ignore # noqa: PGH003 + rekey_to=None, + ) + + transaction_with_signer = TransactionWithSigner( + txn=xfer_txn, + signer=account.signer, + ) + atc.add_transaction(transaction_with_signer) + atc.execute(algod_client, 4) + + for index, asset_id in enumerate(chunk): + result[asset_id] = atc.tx_ids[index] + + return result + + +def opt_out(algod_client: "AlgodClient", account: Account, asset_ids: list[int]) -> dict[int, str]: + """ + Opt out from a list of Algorand Standard Assets (ASAs) by transferring them back to their creators. + The account also recovers the Minimum Balance Requirement for the asset (100,000 microAlgos) + The `optOut` function manages the opt-out process, permitting the account to discontinue holding a group of assets. + + It's essential to note that an account can only opt_out of an asset if its balance of that asset is zero. + + Args: + algod_client (AlgodClient): An instance of the AlgodClient class from the `algosdk` library. + account (Account): An instance of the Account class that holds the private key and address for an account. + asset_ids (list[int]): A list of integers representing the asset IDs of the ASAs to opt out from. + Returns: + dict[int, str]: A dictionary where the keys are the asset IDs and the values are the transaction IDs of + the executed transactions. + + """ + _ensure_account_is_valid(algod_client, account) + _ensure_asset_balance_conditions(algod_client, account, asset_ids, ValidationType.OPTOUT) + suggested_params = algod_client.suggested_params() + result = {} + for i in range(0, len(asset_ids), TX_GROUP_LIMIT): + atc = AtomicTransactionComposer() + chunk = asset_ids[i : i + TX_GROUP_LIMIT] + for asset_id in chunk: + asset = algod_client.asset_info(asset_id) + asset_creator = asset["params"]["creator"] # type: ignore # noqa: PGH003 + xfer_txn = AssetTransferTxn( + sp=suggested_params, + sender=account.address, + receiver=account.address, + close_assets_to=asset_creator, + revocation_target=None, + amt=0, + note=f"opt out asset id ${asset_id}", + index=asset["index"], # type: ignore # noqa: PGH003 + rekey_to=None, + ) + + transaction_with_signer = TransactionWithSigner( + txn=xfer_txn, + signer=account.signer, + ) + atc.add_transaction(transaction_with_signer) + atc.execute(algod_client, 4) + + for index, asset_id in enumerate(chunk): + result[asset_id] = atc.tx_ids[index] + + return result diff --git a/src/algokit_utils/_legacy_v2/common.py b/src/algokit_utils/_legacy_v2/common.py new file mode 100644 index 00000000..cd412f82 --- /dev/null +++ b/src/algokit_utils/_legacy_v2/common.py @@ -0,0 +1,28 @@ +""" +This module contains common classes and methods that are reused in more than one file. +""" + +import base64 +import typing + +from algosdk.source_map import SourceMap + +from algokit_utils._legacy_v2.deploy import strip_comments + +if typing.TYPE_CHECKING: + from algosdk.v2client.algod import AlgodClient + + +class Program: + """A compiled TEAL program""" + + def __init__(self, program: str, client: "AlgodClient"): + """ + Fully compile the program source to binary and generate a + source map for matching pc to line number + """ + self.teal = program + result: dict = client.compile(strip_comments(self.teal), source_map=True) + self.raw_binary = base64.b64decode(result["result"]) + self.binary_hash: str = result["hash"] + self.source_map = SourceMap(result["sourcemap"]) diff --git a/src/algokit_utils/_legacy_v2/deploy.py b/src/algokit_utils/_legacy_v2/deploy.py new file mode 100644 index 00000000..561ce413 --- /dev/null +++ b/src/algokit_utils/_legacy_v2/deploy.py @@ -0,0 +1,897 @@ +import base64 +import dataclasses +import json +import logging +import re +from collections.abc import Iterable, Mapping, Sequence +from enum import Enum +from typing import TYPE_CHECKING, TypeAlias, TypedDict + +from algosdk import transaction +from algosdk.atomic_transaction_composer import AtomicTransactionComposer, TransactionSigner +from algosdk.logic import get_application_address +from algosdk.transaction import StateSchema + +from algokit_utils._legacy_v2.application_specification import ( + ApplicationSpecification, + CallConfig, + MethodConfigDict, + OnCompleteActionName, +) +from algokit_utils._legacy_v2.models import ( + ABIArgsDict, + ABIMethod, + Account, + CreateCallParameters, + TransactionResponse, +) + +if TYPE_CHECKING: + from algosdk.v2client.algod import AlgodClient + from algosdk.v2client.indexer import IndexerClient + + from algokit_utils._legacy_v2.application_client import ApplicationClient + + +__all__ = [ + "UPDATABLE_TEMPLATE_NAME", + "DELETABLE_TEMPLATE_NAME", + "NOTE_PREFIX", + "ABICallArgs", + "ABICreateCallArgs", + "ABICallArgsDict", + "ABICreateCallArgsDict", + "DeploymentFailedError", + "AppReference", + "AppDeployMetaData", + "AppMetaData", + "AppLookup", + "DeployCallArgs", + "DeployCreateCallArgs", + "DeployCallArgsDict", + "DeployCreateCallArgsDict", + "Deployer", + "DeployResponse", + "OnUpdate", + "OnSchemaBreak", + "OperationPerformed", + "TemplateValueDict", + "TemplateValueMapping", + "get_app_id_from_tx_id", + "get_creator_apps", + "replace_template_variables", +] + +logger = logging.getLogger(__name__) + +DEFAULT_INDEXER_MAX_API_RESOURCES_PER_ACCOUNT = 1000 +_UPDATABLE = "UPDATABLE" +_DELETABLE = "DELETABLE" +UPDATABLE_TEMPLATE_NAME = f"TMPL_{_UPDATABLE}" +"""Template variable name used to control if a smart contract is updatable or not at deployment""" +DELETABLE_TEMPLATE_NAME = f"TMPL_{_DELETABLE}" +"""Template variable name used to control if a smart contract is deletable or not at deployment""" +_TOKEN_PATTERN = re.compile(r"TMPL_[A-Z_]+") +TemplateValue: TypeAlias = int | str | bytes +TemplateValueDict: TypeAlias = dict[str, TemplateValue] +"""Dictionary of `dict[str, int | str | bytes]` representing template variable names and values""" +TemplateValueMapping: TypeAlias = Mapping[str, TemplateValue] +"""Mapping of `str` to `int | str | bytes` representing template variable names and values""" + +NOTE_PREFIX = "ALGOKIT_DEPLOYER:j" +"""ARC-0002 compliant note prefix for algokit_utils deployed applications""" +# This prefix is also used to filter for parsable transaction notes in get_creator_apps. +# However, as the note is base64 encoded first we need to consider it's base64 representation. +# When base64 encoding bytes, 3 bytes are stored in every 4 characters. +# So then we don't need to worry about the padding/changing characters of the prefix if it was followed by +# additional characters, assert the NOTE_PREFIX length is a multiple of 3. +assert len(NOTE_PREFIX) % 3 == 0 + + +class DeploymentFailedError(Exception): + pass + + +@dataclasses.dataclass +class AppReference: + """Information about an Algorand app""" + + app_id: int + app_address: str + + +@dataclasses.dataclass +class AppDeployMetaData: + """Metadata about an application stored in a transaction note during creation. + + The note is serialized as JSON and prefixed with {py:data}`NOTE_PREFIX` and stored in the transaction note field + as part of {py:meth}`ApplicationClient.deploy` + """ + + name: str + version: str + deletable: bool | None + updatable: bool | None + + @staticmethod + def from_json(value: str) -> "AppDeployMetaData": + json_value: dict = json.loads(value) + json_value.setdefault("deletable", None) + json_value.setdefault("updatable", None) + return AppDeployMetaData(**json_value) + + @classmethod + def from_b64(cls: type["AppDeployMetaData"], b64: str) -> "AppDeployMetaData": + return cls.decode(base64.b64decode(b64)) + + @classmethod + def decode(cls: type["AppDeployMetaData"], value: bytes) -> "AppDeployMetaData": + note = value.decode("utf-8") + assert note.startswith(NOTE_PREFIX) + return cls.from_json(note[len(NOTE_PREFIX) :]) + + def encode(self) -> bytes: + json_str = json.dumps(self.__dict__) + return f"{NOTE_PREFIX}{json_str}".encode() + + +@dataclasses.dataclass +class AppMetaData(AppReference, AppDeployMetaData): + """Metadata about a deployed app""" + + created_round: int + updated_round: int + created_metadata: AppDeployMetaData + deleted: bool + + +@dataclasses.dataclass +class AppLookup: + """Cache of {py:class}`AppMetaData` for a specific `creator` + + Can be used as an argument to {py:class}`ApplicationClient` to reduce the number of calls when deploying multiple + apps or discovering multiple app_ids + """ + + creator: str + apps: dict[str, AppMetaData] = dataclasses.field(default_factory=dict) + + +def _sort_by_round(txn: dict) -> tuple[int, int]: + confirmed = txn["confirmed-round"] + offset = txn["intra-round-offset"] + return confirmed, offset + + +def _parse_note(metadata_b64: str | None) -> AppDeployMetaData | None: + if not metadata_b64: + return None + # noinspection PyBroadException + try: + return AppDeployMetaData.from_b64(metadata_b64) + except Exception: + return None + + +def get_creator_apps(indexer: "IndexerClient", creator_account: Account | str) -> AppLookup: + """Returns a mapping of Application names to {py:class}`AppMetaData` for all Applications created by specified + creator that have a transaction note containing {py:class}`AppDeployMetaData` + """ + apps: dict[str, AppMetaData] = {} + + creator_address = creator_account if isinstance(creator_account, str) else creator_account.address + token = None + # TODO: paginated indexer call instead of N + 1 calls + while True: + response = indexer.lookup_account_application_by_creator( + creator_address, limit=DEFAULT_INDEXER_MAX_API_RESOURCES_PER_ACCOUNT, next_page=token + ) # type: ignore[no-untyped-call] + if "message" in response: # an error occurred + raise Exception(f"Error querying applications for {creator_address}: {response}") + for app in response["applications"]: + app_id = app["id"] + app_created_at_round = app["created-at-round"] + app_deleted = app.get("deleted", False) + search_transactions_response = indexer.search_transactions( + min_round=app_created_at_round, + txn_type="appl", + application_id=app_id, + address=creator_address, + address_role="sender", + note_prefix=NOTE_PREFIX.encode("utf-8"), + ) # type: ignore[no-untyped-call] + transactions: list[dict] = search_transactions_response["transactions"] + if not transactions: + continue + + created_transaction = next( + t + for t in transactions + if t["application-transaction"]["application-id"] == 0 and t["sender"] == creator_address + ) + + transactions.sort(key=_sort_by_round, reverse=True) + latest_transaction = transactions[0] + app_updated_at_round = latest_transaction["confirmed-round"] + + create_metadata = _parse_note(created_transaction.get("note")) + update_metadata = _parse_note(latest_transaction.get("note")) + + if create_metadata and create_metadata.name: + apps[create_metadata.name] = AppMetaData( + app_id=app_id, + app_address=get_application_address(app_id), + created_metadata=create_metadata, + created_round=app_created_at_round, + **(update_metadata or create_metadata).__dict__, + updated_round=app_updated_at_round, + deleted=app_deleted, + ) + + token = response.get("next-token") + if not token: + break + + return AppLookup(creator_address, apps) + + +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 _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}" + + +@dataclasses.dataclass(kw_only=True) +class AppChanges: + app_updated: bool + schema_breaking_change: bool + schema_change_description: str | None + + +def check_for_app_changes( # noqa: PLR0913 + 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), + ) + + +def _is_valid_token_character(char: str) -> bool: + return char.isalnum() or char == "_" + + +def _replace_template_variable(program_lines: list[str], template_variable: str, value: str) -> tuple[list[str], int]: + result: list[str] = [] + match_count = 0 + token = f"TMPL_{template_variable}" + token_idx_offset = len(value) - len(token) + for line in program_lines: + comment_idx = _find_unquoted_string(line, "//") + if comment_idx is None: + comment_idx = len(line) + code = line[:comment_idx] + comment = line[comment_idx:] + trailing_idx = 0 + while True: + token_idx = _find_template_token(code, token, trailing_idx) + if token_idx is None: + break + + trailing_idx = token_idx + len(token) + prefix = code[:token_idx] + suffix = code[trailing_idx:] + code = f"{prefix}{value}{suffix}" + match_count += 1 + trailing_idx += token_idx_offset + result.append(code + comment) + return result, match_count + + +def add_deploy_template_variables( + template_values: TemplateValueDict, allow_update: bool | None, allow_delete: bool | None +) -> None: + if allow_update is not None: + template_values[_UPDATABLE] = int(allow_update) + if allow_delete is not None: + template_values[_DELETABLE] = int(allow_delete) + + +def _find_unquoted_string(line: str, token: str, start: int = 0, end: int = -1) -> int | None: + """Find the first string within a line of TEAL. Only matches outside of quotes and base64 are returned. + Returns None if not found""" + + if end < 0: + end = len(line) + idx = start + in_quotes = in_base64 = False + while idx < end: + current_char = line[idx] + match current_char: + # enter base64 + case " " | "(" if not in_quotes and _last_token_base64(line, idx): + in_base64 = True + # exit base64 + case " " | ")" if not in_quotes and in_base64: + in_base64 = False + # escaped char + case "\\" if in_quotes: + # skip next character + idx += 1 + # quote boundary + case '"': + in_quotes = not in_quotes + # can test for match + case _ if not in_quotes and not in_base64 and line.startswith(token, idx): + # only match if not in quotes and string matches + return idx + idx += 1 + return None + + +def _last_token_base64(line: str, idx: int) -> bool: + try: + *_, last = line[:idx].split() + except ValueError: + return False + return last in ("base64", "b64") + + +def _find_template_token(line: str, token: str, start: int = 0, end: int = -1) -> int | None: + """Find the first template token within a line of TEAL. Only matches outside of quotes are returned. + Only full token matches are returned, i.e. TMPL_STR will not match against TMPL_STRING + Returns None if not found""" + if end < 0: + end = len(line) + + idx = start + while idx < end: + token_idx = _find_unquoted_string(line, token, idx, end) + if token_idx is None: + break + trailing_idx = token_idx + len(token) + if (token_idx == 0 or not _is_valid_token_character(line[token_idx - 1])) and ( # word boundary at start + trailing_idx >= len(line) or not _is_valid_token_character(line[trailing_idx]) # word boundary at end + ): + return token_idx + idx = trailing_idx + return None + + +def _strip_comment(line: str) -> str: + comment_idx = _find_unquoted_string(line, "//") + if comment_idx is None: + return line + return line[:comment_idx].rstrip() + + +def strip_comments(program: str) -> str: + return "\n".join(_strip_comment(line) for line in program.splitlines()) + + +def _has_token(program_without_comments: str, token: str) -> bool: + for line in program_without_comments.splitlines(): + token_idx = _find_template_token(line, token) + if token_idx is not None: + return True + return False + + +def _find_tokens(stripped_approval_program: str) -> list[str]: + return _TOKEN_PATTERN.findall(stripped_approval_program) + + +def check_template_variables(approval_program: str, template_values: TemplateValueDict) -> None: + approval_program = strip_comments(approval_program) + if _has_token(approval_program, UPDATABLE_TEMPLATE_NAME) and _UPDATABLE not in template_values: + raise DeploymentFailedError( + "allow_update must be specified if deploy time configuration of update is being used" + ) + if _has_token(approval_program, DELETABLE_TEMPLATE_NAME) and _DELETABLE not in template_values: + raise DeploymentFailedError( + "allow_delete must be specified if deploy time configuration of delete is being used" + ) + all_tokens = _find_tokens(approval_program) + missing_values = [token for token in all_tokens if token[len("TMPL_") :] not in template_values] + if missing_values: + raise DeploymentFailedError(f"The following template values were not provided: {', '.join(missing_values)}") + + for template_variable_name in template_values: + tmpl_variable = f"TMPL_{template_variable_name}" + if not _has_token(approval_program, tmpl_variable): + if template_variable_name == _UPDATABLE: + raise DeploymentFailedError( + "allow_update must only be specified if deploy time configuration of update is being used" + ) + if template_variable_name == _DELETABLE: + raise DeploymentFailedError( + "allow_delete must only be specified if deploy time configuration of delete is being used" + ) + logger.warning(f"{tmpl_variable} not found in approval program, but variable was provided") + + +def replace_template_variables(program: str, template_values: TemplateValueMapping) -> str: + """Replaces `TMPL_*` variables in `program` with `template_values` + + ```{note} + `template_values` keys should *NOT* be prefixed with `TMPL_` + ``` + """ + program_lines = program.splitlines() + for template_variable_name, template_value in template_values.items(): + match template_value: + case int(): + value = str(template_value) + case str(): + value = "0x" + template_value.encode("utf-8").hex() + case bytes(): + value = "0x" + template_value.hex() + case _: + raise DeploymentFailedError( + f"Unexpected template value type {template_variable_name}: {template_value.__class__}" + ) + + program_lines, matches = _replace_template_variable(program_lines, template_variable_name, value) + + return "\n".join(program_lines) + + +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): + """Action to take if an Application has been updated""" + + Fail = 0 + """Fail the deployment""" + UpdateApp = 1 + """Update the Application with the new approval and clear programs""" + ReplaceApp = 2 + """Create a new Application and delete the old Application in a single transaction""" + AppendApp = 3 + """Create a new application""" + + +class OnSchemaBreak(Enum): + """Action to take if an Application's schema has breaking changes""" + + Fail = 0 + """Fail the deployment""" + ReplaceApp = 2 + """Create a new Application and delete the old Application in a single transaction""" + AppendApp = 3 + """Create a new Application""" + + +class OperationPerformed(Enum): + """Describes the actions taken during deployment""" + + Nothing = 0 + """An existing Application was found""" + Create = 1 + """No existing Application was found, created a new Application""" + Update = 2 + """An existing Application was found, but was out of date, updated to latest version""" + Replace = 3 + """An existing Application was found, but was out of date, created a new Application and deleted the original""" + + +@dataclasses.dataclass(kw_only=True) +class DeployResponse: + """Describes the action taken during deployment, related transactions and the {py:class}`AppMetaData`""" + + app: AppMetaData + create_response: TransactionResponse | None = None + delete_response: TransactionResponse | None = None + update_response: TransactionResponse | None = None + action_taken: OperationPerformed = OperationPerformed.Nothing + + +@dataclasses.dataclass(kw_only=True) +class DeployCallArgs: + """Parameters used to update or delete an application when calling + {py:meth}`~algokit_utils.ApplicationClient.deploy`""" + + 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 ABICall: + method: ABIMethod | bool | None = None + args: ABIArgsDict = dataclasses.field(default_factory=dict) + + +@dataclasses.dataclass(kw_only=True) +class DeployCreateCallArgs(DeployCallArgs): + """Parameters used to create an application when calling {py:meth}`~algokit_utils.ApplicationClient.deploy`""" + + extra_pages: int | None = None + on_complete: transaction.OnComplete | None = None + + +@dataclasses.dataclass(kw_only=True) +class ABICallArgs(DeployCallArgs, ABICall): + """ABI Parameters used to update or delete an application when calling + {py:meth}`~algokit_utils.ApplicationClient.deploy`""" + + +@dataclasses.dataclass(kw_only=True) +class ABICreateCallArgs(DeployCreateCallArgs, ABICall): + """ABI Parameters used to create an application when calling {py:meth}`~algokit_utils.ApplicationClient.deploy`""" + + +class DeployCallArgsDict(TypedDict, total=False): + """Parameters used to update or delete an application when calling + {py:meth}`~algokit_utils.ApplicationClient.deploy`""" + + 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 ABICallArgsDict(DeployCallArgsDict, TypedDict, total=False): + """ABI Parameters used to update or delete an application when calling + {py:meth}`~algokit_utils.ApplicationClient.deploy`""" + + method: ABIMethod | bool + args: ABIArgsDict + + +class DeployCreateCallArgsDict(DeployCallArgsDict, TypedDict, total=False): + """Parameters used to create an application when calling {py:meth}`~algokit_utils.ApplicationClient.deploy`""" + + extra_pages: int | None + on_complete: transaction.OnComplete + + +class ABICreateCallArgsDict(DeployCreateCallArgsDict, TypedDict, total=False): + """ABI Parameters used to create an application when calling {py:meth}`~algokit_utils.ApplicationClient.deploy`""" + + method: ABIMethod | bool + args: ABIArgsDict + + +@dataclasses.dataclass(kw_only=True) +class Deployer: + app_client: "ApplicationClient" + creator: str + signer: TransactionSigner + sender: str + existing_app_metadata_or_reference: AppReference | AppMetaData + new_app_metadata: AppDeployMetaData + on_update: OnUpdate + on_schema_break: OnSchemaBreak + create_args: ABICreateCallArgs | ABICreateCallArgsDict | DeployCreateCallArgs | None + update_args: ABICallArgs | ABICallArgsDict | DeployCallArgs | None + delete_args: ABICallArgs | ABICallArgsDict | DeployCallArgs | None + + def deploy(self) -> DeployResponse: + """Ensures app associated with app client's creator is present and up to date""" + assert self.app_client.approval + assert self.app_client.clear + + if self.existing_app_metadata_or_reference.app_id == 0: + logger.info(f"{self.new_app_metadata.name} not found in {self.creator} account, deploying app.") + return self._create_app() + + assert isinstance(self.existing_app_metadata_or_reference, AppMetaData) + logger.debug( + f"{self.existing_app_metadata_or_reference.name} found in {self.creator} account, " + f"with app id {self.existing_app_metadata_or_reference.app_id}, " + f"version={self.existing_app_metadata_or_reference.version}." + ) + + app_changes = check_for_app_changes( + self.app_client.algod_client, + new_approval=self.app_client.approval.raw_binary, + new_clear=self.app_client.clear.raw_binary, + new_global_schema=self.app_client.app_spec.global_state_schema, + new_local_schema=self.app_client.app_spec.local_state_schema, + app_id=self.existing_app_metadata_or_reference.app_id, + ) + + if app_changes.schema_breaking_change: + logger.warning(f"Detected a breaking app schema change: {app_changes.schema_change_description}") + return self._deploy_breaking_change() + + if app_changes.app_updated: + logger.info(f"Detected a TEAL update in app id {self.existing_app_metadata_or_reference.app_id}") + return self._deploy_update() + + logger.info("No detected changes in app, nothing to do.") + return DeployResponse(app=self.existing_app_metadata_or_reference) + + def _deploy_breaking_change(self) -> DeployResponse: + assert isinstance(self.existing_app_metadata_or_reference, AppMetaData) + if self.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 self.on_schema_break == OnSchemaBreak.AppendApp: + logger.info("Schema break detected and on_schema_break=AppendApp, will attempt to create new app") + return self._create_app() + + if self.existing_app_metadata_or_reference.deletable: + logger.info( + "App is deletable and on_schema_break=ReplaceApp, will attempt to create new app and delete old app" + ) + elif self.existing_app_metadata_or_reference.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 self._create_and_delete_app() + + def _deploy_update(self) -> DeployResponse: + assert isinstance(self.existing_app_metadata_or_reference, AppMetaData) + if self.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 self.on_update == OnUpdate.AppendApp: + logger.info("Update detected and on_update=AppendApp, will attempt to create new app") + return self._create_app() + elif self.existing_app_metadata_or_reference.updatable and self.on_update == OnUpdate.UpdateApp: + logger.info("App is updatable and on_update=UpdateApp, will update app") + return self._update_app() + elif self.existing_app_metadata_or_reference.updatable and self.on_update == OnUpdate.ReplaceApp: + logger.warning( + "App is updatable but on_update=ReplaceApp, will attempt to create new app and delete old app" + ) + return self._create_and_delete_app() + elif self.on_update == OnUpdate.ReplaceApp: + if self.existing_app_metadata_or_reference.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 self._create_and_delete_app() + else: + if self.existing_app_metadata_or_reference.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 self._update_app() + + def _create_app(self) -> DeployResponse: + assert self.app_client.existing_deployments + + method, abi_args, parameters = _convert_deploy_args( + self.create_args, self.new_app_metadata, self.signer, self.sender + ) + create_response = self.app_client.create( + method, + parameters, + **abi_args, + ) + logger.info( + f"{self.new_app_metadata.name} ({self.new_app_metadata.version}) deployed successfully, " + f"with app id {self.app_client.app_id}." + ) + assert create_response.confirmed_round is not None + app_metadata = _create_metadata(self.new_app_metadata, self.app_client.app_id, create_response.confirmed_round) + self.app_client.existing_deployments.apps[self.new_app_metadata.name] = app_metadata + return DeployResponse(app=app_metadata, create_response=create_response, action_taken=OperationPerformed.Create) + + def _create_and_delete_app(self) -> DeployResponse: + assert self.app_client.existing_deployments + assert isinstance(self.existing_app_metadata_or_reference, AppMetaData) + + logger.info( + f"Replacing {self.existing_app_metadata_or_reference.name} " + f"({self.existing_app_metadata_or_reference.version}) with " + f"{self.new_app_metadata.name} ({self.new_app_metadata.version}) in {self.creator} account." + ) + atc = AtomicTransactionComposer() + create_method, create_abi_args, create_parameters = _convert_deploy_args( + self.create_args, self.new_app_metadata, self.signer, self.sender + ) + self.app_client.compose_create( + atc, + create_method, + create_parameters, + **create_abi_args, + ) + create_txn_index = len(atc.txn_list) - 1 + delete_method, delete_abi_args, delete_parameters = _convert_deploy_args( + self.delete_args, self.new_app_metadata, self.signer, self.sender + ) + self.app_client.compose_delete( + atc, + delete_method, + delete_parameters, + **delete_abi_args, + ) + delete_txn_index = len(atc.txn_list) - 1 + create_delete_response = self.app_client.execute_atc(atc) + create_response = TransactionResponse.from_atr(create_delete_response, create_txn_index) + delete_response = TransactionResponse.from_atr(create_delete_response, delete_txn_index) + self.app_client.app_id = get_app_id_from_tx_id(self.app_client.algod_client, create_response.tx_id) + logger.info( + f"{self.new_app_metadata.name} ({self.new_app_metadata.version}) deployed successfully, " + f"with app id {self.app_client.app_id}." + ) + logger.info( + f"{self.existing_app_metadata_or_reference.name} " + f"({self.existing_app_metadata_or_reference.version}) with app id " + f"{self.existing_app_metadata_or_reference.app_id}, deleted successfully." + ) + + app_metadata = _create_metadata( + self.new_app_metadata, self.app_client.app_id, create_delete_response.confirmed_round + ) + self.app_client.existing_deployments.apps[self.new_app_metadata.name] = app_metadata + + return DeployResponse( + app=app_metadata, + create_response=create_response, + delete_response=delete_response, + action_taken=OperationPerformed.Replace, + ) + + def _update_app(self) -> DeployResponse: + assert self.app_client.existing_deployments + assert isinstance(self.existing_app_metadata_or_reference, AppMetaData) + + logger.info( + f"Updating {self.existing_app_metadata_or_reference.name} to {self.new_app_metadata.version} in " + f"{self.creator} account, with app id {self.existing_app_metadata_or_reference.app_id}" + ) + method, abi_args, parameters = _convert_deploy_args( + self.update_args, self.new_app_metadata, self.signer, self.sender + ) + update_response = self.app_client.update( + method, + parameters, + **abi_args, + ) + app_metadata = _create_metadata( + self.new_app_metadata, + self.app_client.app_id, + self.existing_app_metadata_or_reference.created_round, + updated_round=update_response.confirmed_round, + original_metadata=self.existing_app_metadata_or_reference.created_metadata, + ) + self.app_client.existing_deployments.apps[self.new_app_metadata.name] = app_metadata + return DeployResponse(app=app_metadata, update_response=update_response, action_taken=OperationPerformed.Update) + + +def _create_metadata( + app_spec_note: AppDeployMetaData, + app_id: int, + created_round: int, + updated_round: int | None = None, + original_metadata: AppDeployMetaData | None = None, +) -> AppMetaData: + return 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, + name=app_spec_note.name, + version=app_spec_note.version, + deletable=app_spec_note.deletable, + updatable=app_spec_note.updatable, + deleted=False, + ) + + +def _convert_deploy_args( + _args: DeployCallArgs | DeployCallArgsDict | None, + note: AppDeployMetaData, + signer: TransactionSigner | None, + sender: str | None, +) -> tuple[ABIMethod | bool | None, ABIArgsDict, CreateCallParameters]: + args = _args.__dict__ if isinstance(_args, DeployCallArgs) else dict(_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_app_id_from_tx_id(algod_client: "AlgodClient", tx_id: str) -> int: + """Finds the app_id for provided transaction id""" + 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 diff --git a/src/algokit_utils/_legacy_v2/logic_error.py b/src/algokit_utils/_legacy_v2/logic_error.py new file mode 100644 index 00000000..a365a3c1 --- /dev/null +++ b/src/algokit_utils/_legacy_v2/logic_error.py @@ -0,0 +1,85 @@ +import re +from copy import copy +from typing import TYPE_CHECKING, TypedDict + +from algokit_utils._legacy_v2.models import SimulationTrace + +if TYPE_CHECKING: + from algosdk.source_map import SourceMap as AlgoSourceMap + +__all__ = [ + "LogicError", + "parse_logic_error", +] + +LOGIC_ERROR = ( + ".*transaction (?P[A-Z0-9]+): logic eval error: (?P.*). Details: .*pc=(?P[0-9]+).*" +) + + +class LogicErrorData(TypedDict): + transaction_id: str + message: str + pc: int + + +def parse_logic_error( + error_str: str, +) -> LogicErrorData | None: + match = re.match(LOGIC_ERROR, error_str) + if match is None: + return None + + return { + "transaction_id": match.group("transaction_id"), + "message": match.group("message"), + "pc": int(match.group("pc")), + } + + +class LogicError(Exception): + def __init__( # noqa: PLR0913 + self, + *, + logic_error_str: str, + program: str, + source_map: "AlgoSourceMap | None", + transaction_id: str, + message: str, + pc: int, + logic_error: Exception | None = None, + traces: list[SimulationTrace] | None = None, + ): + self.logic_error = logic_error + self.logic_error_str = logic_error_str + self.program = program + self.source_map = source_map + self.lines = program.split("\n") + self.transaction_id = transaction_id + self.message = message + self.pc = pc + self.traces = traces + + self.line_no = self.source_map.get_line_for_pc(self.pc) if self.source_map else None + + def __str__(self) -> str: + return ( + f"Txn {self.transaction_id} had error '{self.message}' at PC {self.pc}" + + (":" if self.line_no is None else f" and Source Line {self.line_no}:") + + f"\n{self.trace()}" + ) + + def trace(self, lines: int = 5) -> str: + if self.line_no is None: + return """ +Could not determine TEAL source line for the error as no approval source map was provided, to receive a trace of the +error please provide an approval SourceMap. Either by: + 1.) Providing template_values when creating the ApplicationClient, so a SourceMap can be obtained automatically OR + 2.) Set approval_source_map from a previously compiled approval program OR + 3.) Import a previously exported source map using import_source_map""" + + program_lines = copy(self.lines) + program_lines[self.line_no] += "\t\t<-- Error" + lines_before = max(0, self.line_no - lines) + lines_after = min(len(program_lines), self.line_no + lines) + return "\n\t" + "\n\t".join(program_lines[lines_before:lines_after]) diff --git a/src/algokit_utils/models.py b/src/algokit_utils/_legacy_v2/models.py similarity index 97% rename from src/algokit_utils/models.py rename to src/algokit_utils/_legacy_v2/models.py index e1030088..cc5d34d2 100644 --- a/src/algokit_utils/models.py +++ b/src/algokit_utils/_legacy_v2/models.py @@ -202,14 +202,14 @@ class TransactionParametersDict(TypedDict, total=False): """Address to rekey to""" -class OnCompleteCallParametersDict(TypedDict, TransactionParametersDict, total=False): +class OnCompleteCallParametersDict(TransactionParametersDict, total=False): """Additional parameters that can be included in a transaction when using the ApplicationClient.call/compose_call methods""" on_complete: transaction.OnComplete -class CreateCallParametersDict(TypedDict, OnCompleteCallParametersDict, total=False): +class CreateCallParametersDict(OnCompleteCallParametersDict, total=False): """Additional parameters that can be included in a transaction when using the ApplicationClient.create/compose_create methods""" diff --git a/src/algokit_utils/_legacy_v2/network_clients.py b/src/algokit_utils/_legacy_v2/network_clients.py new file mode 100644 index 00000000..2de270da --- /dev/null +++ b/src/algokit_utils/_legacy_v2/network_clients.py @@ -0,0 +1,130 @@ +import dataclasses +import os +from typing import Literal +from urllib import parse + +from algosdk.kmd import KMDClient +from algosdk.v2client.algod import AlgodClient +from algosdk.v2client.indexer import IndexerClient + +__all__ = [ + "AlgoClientConfig", + "get_algod_client", + "get_algonode_config", + "get_default_localnet_config", + "get_indexer_client", + "get_kmd_client_from_algod_client", + "is_localnet", + "is_mainnet", + "is_testnet", + "AlgoClientConfigs", + "get_kmd_client", +] + + +@dataclasses.dataclass +class AlgoClientConfig: + """Connection details for connecting to an {py:class}`algosdk.v2client.algod.AlgodClient` or + {py:class}`algosdk.v2client.indexer.IndexerClient`""" + + server: str + """URL for the service e.g. `http://localhost:4001` or `https://testnet-api.algonode.cloud`""" + token: str + """API Token to authenticate with the service""" + + +@dataclasses.dataclass +class AlgoClientConfigs: + algod_config: AlgoClientConfig + indexer_config: AlgoClientConfig + kmd_config: AlgoClientConfig | None + + +def get_default_localnet_config(config: Literal["algod", "indexer", "kmd"]) -> AlgoClientConfig: + """Returns the client configuration to point to the default LocalNet""" + port = {"algod": 4001, "indexer": 8980, "kmd": 4002}[config] + return AlgoClientConfig(server=f"http://localhost:{port}", token="a" * 64) + + +def get_algonode_config( + network: Literal["testnet", "mainnet"], config: Literal["algod", "indexer"], token: str +) -> AlgoClientConfig: + client = "api" if config == "algod" else "idx" + return AlgoClientConfig( + server=f"https://{network}-{client}.algonode.cloud", + token=token, + ) + + +def get_algod_client(config: AlgoClientConfig | None = None) -> AlgodClient: + """Returns an {py:class}`algosdk.v2client.algod.AlgodClient` from `config` or environment + + If no configuration provided will use environment variables `ALGOD_SERVER`, `ALGOD_PORT` and `ALGOD_TOKEN`""" + config = config or _get_config_from_environment("ALGOD") + headers = {"X-Algo-API-Token": config.token} + return AlgodClient(config.token, config.server, headers) + + +def get_kmd_client(config: AlgoClientConfig | None = None) -> KMDClient: + """Returns an {py:class}`algosdk.kmd.KMDClient` from `config` or environment + + If no configuration provided will use environment variables `KMD_SERVER`, `KMD_PORT` and `KMD_TOKEN`""" + config = config or _get_config_from_environment("KMD") + return KMDClient(config.token, config.server) # type: ignore[no-untyped-call] + + +def get_indexer_client(config: AlgoClientConfig | None = None) -> IndexerClient: + """Returns an {py:class}`algosdk.v2client.indexer.IndexerClient` from `config` or environment. + + If no configuration provided will use environment variables `INDEXER_SERVER`, `INDEXER_PORT` and `INDEXER_TOKEN`""" + config = config or _get_config_from_environment("INDEXER") + headers = {"X-Indexer-API-Token": config.token} + return IndexerClient(config.token, config.server, headers) # type: ignore[no-untyped-call] + + +def is_localnet(client: AlgodClient) -> bool: + """Returns True if client genesis is `devnet-v1` or `sandnet-v1`""" + params = client.suggested_params() + return params.gen in ["devnet-v1", "sandnet-v1", "dockernet-v1"] + + +def is_mainnet(client: AlgodClient) -> bool: + """Returns True if client genesis is `mainnet-v1`""" + params = client.suggested_params() + return params.gen in ["mainnet-v1.0", "mainnet-v1", "mainnet"] + + +def is_testnet(client: AlgodClient) -> bool: + """Returns True if client genesis is `testnet-v1`""" + params = client.suggested_params() + return params.gen in ["testnet-v1.0", "testnet-v1", "testnet"] + + +def get_kmd_client_from_algod_client(client: AlgodClient) -> KMDClient: + """Returns an {py:class}`algosdk.kmd.KMDClient` from supplied `client` + + Will use the same address as provided `client` but on port specified by `KMD_PORT` environment variable, + or 4002 by default""" + # We can only use Kmd on the LocalNet otherwise it's not exposed so this makes some assumptions + # (e.g. same token and server as algod and port 4002 by default) + port = os.getenv("KMD_PORT", "4002") + server = _replace_kmd_port(client.algod_address, port) + return KMDClient(client.algod_token, server) # type: ignore[no-untyped-call] + + +def _replace_kmd_port(address: str, port: str) -> str: + parsed_algod = parse.urlparse(address) + kmd_host = parsed_algod.netloc.split(":", maxsplit=1)[0] + f":{port}" + kmd_parsed = parsed_algod._replace(netloc=kmd_host) + return parse.urlunparse(kmd_parsed) + + +def _get_config_from_environment(environment_prefix: str) -> AlgoClientConfig: + server = os.getenv(f"{environment_prefix}_SERVER") + if server is None: + raise Exception(f"Server environment variable not set: {environment_prefix}_SERVER") + port = os.getenv(f"{environment_prefix}_PORT") + if port: + parsed = parse.urlparse(server) + server = parsed._replace(netloc=f"{parsed.hostname}:{port}").geturl() + return AlgoClientConfig(server, os.getenv(f"{environment_prefix}_TOKEN", "")) diff --git a/src/algokit_utils/account.py b/src/algokit_utils/account.py index a0eb7d53..cb51b335 100644 --- a/src/algokit_utils/account.py +++ b/src/algokit_utils/account.py @@ -1,183 +1 @@ -import logging -import os -from typing import TYPE_CHECKING, Any - -from algosdk.account import address_from_private_key -from algosdk.mnemonic import from_private_key, to_private_key -from algosdk.util import algos_to_microalgos - -from algokit_utils._transfer import TransferParameters, transfer -from algokit_utils.models import Account -from algokit_utils.network_clients import get_kmd_client_from_algod_client, is_localnet - -if TYPE_CHECKING: - from collections.abc import Callable - - from algosdk.kmd import KMDClient - from algosdk.v2client.algod import AlgodClient - -__all__ = [ - "create_kmd_wallet_account", - "get_account", - "get_account_from_mnemonic", - "get_dispenser_account", - "get_kmd_wallet_account", - "get_localnet_default_account", - "get_or_create_kmd_wallet_account", -] - -logger = logging.getLogger(__name__) -_DEFAULT_ACCOUNT_MINIMUM_BALANCE = 1_000_000_000 - - -def get_account_from_mnemonic(mnemonic: str) -> Account: - """Convert a mnemonic (25 word passphrase) into an Account""" - private_key = to_private_key(mnemonic) # type: ignore[no-untyped-call] - address = address_from_private_key(private_key) # type: ignore[no-untyped-call] - return Account(private_key=private_key, address=address) - - -def create_kmd_wallet_account(kmd_client: "KMDClient", name: str) -> Account: - """Creates a wallet with specified name""" - wallet_id = kmd_client.create_wallet(name, "")["id"] - wallet_handle = kmd_client.init_wallet_handle(wallet_id, "") - kmd_client.generate_key(wallet_handle) - - key_ids: list[str] = kmd_client.list_keys(wallet_handle) - account_key = key_ids[0] - - private_account_key = kmd_client.export_key(wallet_handle, "", account_key) - return get_account_from_mnemonic(from_private_key(private_account_key)) # type: ignore[no-untyped-call] - - -def get_or_create_kmd_wallet_account( - client: "AlgodClient", name: str, fund_with_algos: float = 1000, kmd_client: "KMDClient | None" = None -) -> Account: - """Returns a wallet with specified name, or creates one if not found""" - kmd_client = kmd_client or get_kmd_client_from_algod_client(client) - account = get_kmd_wallet_account(client, kmd_client, name) - - if account: - 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 LocalNet with name '{name}', but no funds in the account.") - else: - account = create_kmd_wallet_account(kmd_client, name) - - logger.debug( - f"Couldn't find existing account in LocalNet 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} ALGOs") - - 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 - - -def _is_default_account(account: dict[str, Any]) -> bool: - return bool(account["status"] != "Offline" and account["amount"] > _DEFAULT_ACCOUNT_MINIMUM_BALANCE) - - -def get_localnet_default_account(client: "AlgodClient") -> Account: - """Returns the default Account in a LocalNet instance""" - if not is_localnet(client): - raise Exception("Can't get a default account from non LocalNet network") - - account = get_kmd_wallet_account( - client, get_kmd_client_from_algod_client(client), "unencrypted-default-wallet", _is_default_account - ) - assert account - return account - - -def get_dispenser_account(client: "AlgodClient") -> Account: - """Returns an Account based on DISPENSER_MNENOMIC environment variable or the default account on LocalNet""" - if is_localnet(client): - return get_localnet_default_account(client) - return get_account(client, "DISPENSER") - - -def get_kmd_wallet_account( - client: "AlgodClient", - kmd_client: "KMDClient", - name: str, - predicate: "Callable[[dict[str, Any]], bool] | None" = None, -) -> Account | None: - """Returns wallet matching specified name and predicate or None if not found""" - wallets: list[dict] = kmd_client.list_wallets() - - wallet = next((w for w in wallets if w["name"] == name), None) - if wallet is None: - return None - - wallet_id = wallet["id"] - wallet_handle = kmd_client.init_wallet_handle(wallet_id, "") - key_ids: list[str] = kmd_client.list_keys(wallet_handle) - matched_account_key = None - if predicate: - for key in key_ids: - account = client.account_info(key) - assert isinstance(account, dict) - if predicate(account): - matched_account_key = key - else: - matched_account_key = next(key_ids.__iter__(), None) - - if not matched_account_key: - return None - - private_account_key = kmd_client.export_key(wallet_handle, "", matched_account_key) - return get_account_from_mnemonic(from_private_key(private_account_key)) # type: ignore[no-untyped-call] - - -def get_account( - client: "AlgodClient", name: str, fund_with_algos: float = 1000, kmd_client: "KMDClient | None" = None -) -> Account: - """Returns an Algorand account with private key loaded by convention based on the given name identifier. - - # Convention - - **Non-LocalNet:** will load `os.environ[f"{name}_MNEMONIC"]` as a mnemonic secret - Be careful how the mnemonic is handled, never commit it into source control and ideally load it via a - secret storage service rather than the file system. - - **LocalNet:** will load the account from a KMD wallet called {name} and if that wallet doesn't exist it will - create it and fund the account for you - - This allows you to write code that will work seamlessly in production and local development (LocalNet) without - manual config locally (including when you reset the LocalNet). - - # Example - If you have a mnemonic secret loaded into `os.environ["ACCOUNT_MNEMONIC"]` then you can call the following to get - that private key loaded into an account object: - ```python - account = get_account('ACCOUNT', algod) - ``` - - If that code runs against LocalNet then a wallet called 'ACCOUNT' will automatically be created with an account - that is automatically funded with 1000 (default) ALGOs from the default LocalNet dispenser. - """ - - mnemonic_key = f"{name.upper()}_MNEMONIC" - mnemonic = os.getenv(mnemonic_key) - if mnemonic: - return get_account_from_mnemonic(mnemonic) - - if is_localnet(client): - account = get_or_create_kmd_wallet_account(client, name, fund_with_algos, kmd_client) - os.environ[mnemonic_key] = from_private_key(account.private_key) # type: ignore[no-untyped-call] - return account - - raise Exception(f"Missing environment variable '{mnemonic_key}' when looking for account '{name}'") +from algokit_utils._legacy_v2.account import * # noqa: F403 diff --git a/src/algokit_utils/accounts/__init__.py b/src/algokit_utils/accounts/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/algokit_utils/beta/account_manager.py b/src/algokit_utils/accounts/account_manager.py similarity index 63% rename from src/algokit_utils/beta/account_manager.py rename to src/algokit_utils/accounts/account_manager.py index 7eddff75..a2527c6c 100644 --- a/src/algokit_utils/beta/account_manager.py +++ b/src/algokit_utils/accounts/account_manager.py @@ -2,12 +2,12 @@ from dataclasses import dataclass from typing import Any -from algokit_utils.account import get_dispenser_account, get_kmd_wallet_account, get_localnet_default_account from algosdk.account import generate_account from algosdk.atomic_transaction_composer import AccountTransactionSigner, TransactionSigner from typing_extensions import Self -from .client_manager import ClientManager +from algokit_utils.account import get_dispenser_account, get_kmd_wallet_account, get_localnet_default_account +from algokit_utils.clients.client_manager import ClientManager @dataclass @@ -86,40 +86,11 @@ def get_asset_information(self, sender: str, asset_id: int) -> dict[str, Any]: assert isinstance(info, dict) return info - # TODO - # def from_mnemonic(self, mnemonic_secret: str, sender: Optional[str] = None) -> AddrAndSigner: - # """ - # Tracks and returns an Algorand account with secret key loaded (i.e. that can sign transactions) by taking the mnemonic secret. - - # Example: - # account = account.from_mnemonic("mnemonic secret ...") - # rekeyed_account = account.from_mnemonic("mnemonic secret ...", "SENDERADDRESS...") - - # :param mnemonic_secret: The mnemonic secret representing the private key of an account; **Note: Be careful how the mnemonic is handled**, - # never commit it into source control and ideally load it from the environment (ideally via a secret storage service) rather than the file system. - # :param sender: The optional sender address to use this signer for (aka a rekeyed account) - # :return: The account - # """ - # account = mnemonic_account(mnemonic_secret) - # return self.signer_account(rekeyed_account(account, sender) if sender else account) - def from_kmd( self, name: str, predicate: Callable[[dict[str, Any]], bool] | None = None, ) -> AddressAndSigner: - """ - Tracks and returns an Algorand account with private key loaded from the given KMD wallet (identified by name). - - Example (Get default funded account in a LocalNet): - default_dispenser_account = account.from_kmd('unencrypted-default-wallet', - lambda a: a['status'] != 'Offline' and a['amount'] > 1_000_000_000 - ) - - :param name: The name of the wallet to retrieve an account from - :param predicate: An optional filter to use to find the account (otherwise it will return a random account from the wallet) - :return: The account - """ account = get_kmd_wallet_account( name=name, predicate=predicate, client=self._client_manager.algod, kmd_client=self._client_manager.kmd ) @@ -129,29 +100,6 @@ def from_kmd( self.set_signer(account.address, account.signer) return AddressAndSigner(address=account.address, signer=account.signer) - # TODO - # def multisig( - # self, multisig_params: algosdk.MultisigMetadata, signing_accounts: Union[algosdk.Account, SigningAccount] - # ) -> TransactionSignerAccount: - # """ - # Tracks and returns an account that supports partial or full multisig signing. - - # Example: - # account = account.multisig( - # { - # "version": 1, - # "threshold": 1, - # "addrs": ["ADDRESS1...", "ADDRESS2..."] - # }, - # account.from_environment('ACCOUNT1') - # ) - - # :param multisig_params: The parameters that define the multisig account - # :param signing_accounts: The signers that are currently present - # :return: A multisig account wrapper - # """ - # return self.signer_account(multisig_account(multisig_params, signing_accounts)) - def random(self) -> AddressAndSigner: """ Tracks and returns a new, random Algorand account with secret key loaded. @@ -187,14 +135,6 @@ def dispenser(self) -> AddressAndSigner: return AddressAndSigner(address=acct.address, signer=acct.signer) def localnet_dispenser(self) -> AddressAndSigner: - """ - Returns an Algorand account with private key loaded for the default LocalNet dispenser account (that can be used to fund other accounts). - - Example: - account = account.localnet_dispenser() - - :return: The account - """ acct = get_localnet_default_account(self._client_manager.algod) self.set_signer(acct.address, acct.signer) return AddressAndSigner(address=acct.address, signer=acct.signer) diff --git a/src/algokit_utils/accounts/models.py b/src/algokit_utils/accounts/models.py new file mode 100644 index 00000000..e69de29b diff --git a/src/algokit_utils/application_client.py b/src/algokit_utils/application_client.py index 008ac32f..2859c5d0 100644 --- a/src/algokit_utils/application_client.py +++ b/src/algokit_utils/application_client.py @@ -1,1449 +1 @@ -import base64 -import copy -import json -import logging -import re -import typing -from math import ceil -from pathlib import Path -from typing import Any, Literal, cast, overload - -import algosdk -from algosdk import transaction -from algosdk.abi import ABIType, Method, Returns -from algosdk.account import address_from_private_key -from algosdk.atomic_transaction_composer import ( - ABI_RETURN_HASH, - ABIResult, - AccountTransactionSigner, - AtomicTransactionComposer, - AtomicTransactionResponse, - LogicSigTransactionSigner, - MultisigTransactionSigner, - SimulateAtomicTransactionResponse, - TransactionSigner, - TransactionWithSigner, -) -from algosdk.constants import APP_PAGE_MAX_SIZE -from algosdk.logic import get_application_address -from algosdk.source_map import SourceMap - -import algokit_utils.application_specification as au_spec -import algokit_utils.deploy as au_deploy -from algokit_utils._debugging import ( - PersistSourceMapInput, - persist_sourcemaps, - simulate_and_persist_response, - simulate_response, -) -from algokit_utils.common import Program -from algokit_utils.config import config -from algokit_utils.logic_error import LogicError, parse_logic_error -from algokit_utils.models import ( - ABIArgsDict, - ABIArgType, - ABIMethod, - ABITransactionResponse, - Account, - CreateCallParameters, - CreateCallParametersDict, - OnCompleteCallParameters, - OnCompleteCallParametersDict, - SimulationTrace, - TransactionParameters, - TransactionParametersDict, - TransactionResponse, -) - -if typing.TYPE_CHECKING: - from algosdk.v2client.algod import AlgodClient - from algosdk.v2client.indexer import IndexerClient - - -logger = logging.getLogger(__name__) - - -"""A dictionary `dict[str, Any]` representing ABI argument names and values""" - -__all__ = [ - "ApplicationClient", - "execute_atc_with_logic_error", - "get_next_version", - "get_sender_from_signer", - "num_extra_program_pages", -] - -"""Alias for {py:class}`pyteal.ABIReturnSubroutine`, {py:class}`algosdk.abi.method.Method` or a {py:class}`str` -representing an ABI method name or signature""" - - -def num_extra_program_pages(approval: bytes, clear: bytes) -> int: - """Calculate minimum number of extra_pages required for provided approval and clear programs""" - - return ceil(((len(approval) + len(clear)) - APP_PAGE_MAX_SIZE) / APP_PAGE_MAX_SIZE) - - -class ApplicationClient: - """A class that wraps an ARC-0032 app spec and provides high productivity methods to deploy and call the app""" - - @overload - def __init__( - self, - algod_client: "AlgodClient", - app_spec: au_spec.ApplicationSpecification | Path, - *, - app_id: int = 0, - signer: TransactionSigner | Account | None = None, - sender: str | None = None, - suggested_params: transaction.SuggestedParams | None = None, - template_values: au_deploy.TemplateValueMapping | None = None, - ): ... - - @overload - def __init__( - self, - algod_client: "AlgodClient", - app_spec: au_spec.ApplicationSpecification | Path, - *, - creator: str | Account, - indexer_client: "IndexerClient | 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.TemplateValueMapping | None = None, - app_name: str | None = None, - ): ... - - def __init__( # noqa: PLR0913 - self, - algod_client: "AlgodClient", - app_spec: au_spec.ApplicationSpecification | Path, - *, - app_id: int = 0, - creator: str | Account | None = None, - indexer_client: "IndexerClient | 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.TemplateValueMapping | None = None, - app_name: str | None = None, - ): - """ApplicationClient can be created with an app_id to interact with an existing application, alternatively - it can be created with a creator and indexer_client specified to find existing applications by name and creator. - - :param AlgodClient algod_client: AlgoSDK algod client - :param ApplicationSpecification | Path app_spec: An Application Specification or the path to one - :param int app_id: The app_id of an existing application, to instead find the application by creator and name - use the creator and indexer_client parameters - :param str | Account creator: The address or Account of the app creator to resolve the app_id - :param IndexerClient indexer_client: AlgoSDK indexer client, only required if deploying or finding app_id by - creator and app name - :param AppLookup existing_deployments: - :param TransactionSigner | Account signer: Account or signer to use to sign transactions, if not specified and - creator was passed as an Account will use that. - :param str sender: Address to use as the sender for all transactions, will use the address associated with the - signer if not specified. - :param TemplateValueMapping template_values: Values to use for TMPL_* template variables, dictionary keys should - *NOT* include the TMPL_ prefix - :param str | None app_name: Name of application to use when deploying, defaults to name defined on the - Application Specification - """ - self.algod_client = algod_client - self.app_spec = ( - au_spec.ApplicationSpecification.from_json(app_spec.read_text()) if isinstance(app_spec, Path) else app_spec - ) - self._app_name = app_name - self._approval_program: Program | None = None - self._approval_source_map: SourceMap | None = None - self._clear_program: Program | None = None - - self.template_values: au_deploy.TemplateValueMapping = template_values or {} - self.existing_deployments = existing_deployments - self._indexer_client = indexer_client - if creator is not None: - if not self.existing_deployments and not self._indexer_client: - raise Exception( - "If using the creator parameter either existing_deployments or indexer_client must also be provided" - ) - self._creator: str | None = creator.address if isinstance(creator, Account) else creator - if self.existing_deployments and self.existing_deployments.creator != self._creator: - raise Exception( - "Attempt to create application client with invalid existing_deployments against" - f"a different creator ({self.existing_deployments.creator} instead of " - f"expected creator {self._creator}" - ) - self.app_id = 0 - else: - self.app_id = app_id - self._creator = None - - self.signer: TransactionSigner | None - if signer: - self.signer = ( - signer if isinstance(signer, TransactionSigner) else AccountTransactionSigner(signer.private_key) - ) - elif isinstance(creator, Account): - self.signer = AccountTransactionSigner(creator.private_key) - else: - self.signer = None - - self.sender = sender - self.suggested_params = suggested_params - - @property - def app_name(self) -> str: - return self._app_name or self.app_spec.contract.name - - @app_name.setter - def app_name(self, value: str) -> None: - self._app_name = value - - @property - def app_address(self) -> str: - return get_application_address(self.app_id) - - @property - def approval(self) -> Program | None: - return self._approval_program - - @property - def approval_source_map(self) -> SourceMap | None: - if self._approval_source_map: - return self._approval_source_map - if self._approval_program: - return self._approval_program.source_map - return None - - @approval_source_map.setter - def approval_source_map(self, value: SourceMap) -> None: - self._approval_source_map = value - - @property - 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": - """Creates a copy of this ApplicationClient, using the new signer, sender and app_id values if provided. - Will also substitute provided template_values into the associated app_spec in the copy""" - new_client: ApplicationClient = copy.copy(self) - new_client._prepare( # noqa: SLF001 - new_client, signer=signer, sender=sender, app_id=app_id, template_values=template_values - ) - return new_client - - def _prepare( # noqa: PLR0913 - 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 - target.signer, target.sender = target.get_signer_sender( - AccountTransactionSigner(signer.private_key) if isinstance(signer, Account) else signer, sender - ) - target.template_values = {**self.template_values, **(template_values or {})} - - def deploy( # noqa: PLR0913 - self, - version: str | None = None, - *, - signer: TransactionSigner | None = None, - sender: str | None = None, - allow_update: bool | None = None, - allow_delete: bool | None = None, - on_update: au_deploy.OnUpdate = au_deploy.OnUpdate.Fail, - on_schema_break: au_deploy.OnSchemaBreak = au_deploy.OnSchemaBreak.Fail, - template_values: au_deploy.TemplateValueMapping | None = None, - create_args: au_deploy.ABICreateCallArgs - | au_deploy.ABICreateCallArgsDict - | au_deploy.DeployCreateCallArgs - | None = None, - update_args: au_deploy.ABICallArgs | au_deploy.ABICallArgsDict | au_deploy.DeployCallArgs | None = None, - delete_args: au_deploy.ABICallArgs | au_deploy.ABICallArgsDict | au_deploy.DeployCallArgs | None = None, - ) -> au_deploy.DeployResponse: - """Deploy an application and update client to reference it. - - Idempotently deploy (create, update/delete if changed) an app against the given name via the given creator - account, including deploy-time template placeholder substitutions. - To understand the architecture decisions behind this functionality please see - - - ```{note} - If there is a breaking state schema change to an existing app (and `on_schema_break` is set to - 'ReplaceApp' the existing app will be deleted and re-created. - ``` - - ```{note} - If there is an update (different TEAL code) to an existing app (and `on_update` is set to 'ReplaceApp') - the existing app will be deleted and re-created. - ``` - - :param str version: version to use when creating or updating app, if None version will be auto incremented - :param algosdk.atomic_transaction_composer.TransactionSigner signer: signer to use when deploying app - , if None uses self.signer - :param str sender: sender address to use when deploying app, if None uses self.sender - :param bool allow_delete: Used to set the `TMPL_DELETABLE` template variable to conditionally control if an app - can be deleted - :param bool allow_update: Used to set the `TMPL_UPDATABLE` template variable to conditionally control if an app - can be updated - :param OnUpdate on_update: Determines what action to take if an application update is required - :param OnSchemaBreak on_schema_break: Determines what action to take if an application schema requirements - has increased beyond the current allocation - :param dict[str, int|str|bytes] template_values: Values to use for `TMPL_*` template variables, dictionary keys - should *NOT* include the TMPL_ prefix - :param ABICreateCallArgs create_args: Arguments used when creating an application - :param ABICallArgs | ABICallArgsDict update_args: Arguments used when updating an application - :param ABICallArgs | ABICallArgsDict delete_args: Arguments used when deleting an application - :return DeployResponse: details action taken and relevant transactions - :raises DeploymentError: If the deployment failed - """ - # check inputs - if self.app_id: - raise au_deploy.DeploymentFailedError( - f"Attempt to deploy app which already has an app index of {self.app_id}" - ) - try: - resolved_signer, resolved_sender = self.resolve_signer_sender(signer, sender) - except ValueError as ex: - raise au_deploy.DeploymentFailedError(f"{ex}, unable to deploy app") from None - if not self._creator: - raise au_deploy.DeploymentFailedError("No creator provided, unable to deploy app") - if self._creator != resolved_sender: - raise au_deploy.DeploymentFailedError( - f"Attempt to deploy contract with a sender address {resolved_sender} that differs " - f"from the given creator address for this application client: {self._creator}" - ) - - # make a copy and prepare variables - template_values = {**self.template_values, **(template_values or {})} - au_deploy.add_deploy_template_variables(template_values, allow_update=allow_update, allow_delete=allow_delete) - - existing_app_metadata_or_reference = self._load_app_reference() - - self._approval_program, self._clear_program = substitute_template_and_compile( - self.algod_client, self.app_spec, template_values - ) - - if config.debug and config.project_root: - persist_sourcemaps( - sources=[ - PersistSourceMapInput( - compiled_teal=self._approval_program, app_name=self.app_name, file_name="approval.teal" - ), - PersistSourceMapInput( - compiled_teal=self._clear_program, app_name=self.app_name, file_name="clear.teal" - ), - ], - project_root=config.project_root, - client=self.algod_client, - with_sources=True, - ) - - deployer = au_deploy.Deployer( - app_client=self, - creator=self._creator, - signer=resolved_signer, - sender=resolved_sender, - new_app_metadata=self._get_app_deploy_metadata(version, allow_update, allow_delete), - existing_app_metadata_or_reference=existing_app_metadata_or_reference, - on_update=on_update, - on_schema_break=on_schema_break, - create_args=create_args, - update_args=update_args, - delete_args=delete_args, - ) - - return deployer.deploy() - - def compose_create( - self, - atc: AtomicTransactionComposer, - /, - call_abi_method: ABIMethod | 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_transaction_parameters(transaction_parameters) - - extra_pages = transaction_parameters.extra_pages or num_extra_program_pages( - approval_program.raw_binary, clear_program.raw_binary - ) - - self.add_method_call( - atc, - app_id=0, - 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, - ) - - @overload - def create( - self, - call_abi_method: Literal[False], - transaction_parameters: CreateCallParameters | CreateCallParametersDict | None = ..., - ) -> TransactionResponse: ... - - @overload - def create( - self, - call_abi_method: ABIMethod | Literal[True], - transaction_parameters: CreateCallParameters | CreateCallParametersDict | None = ..., - **abi_kwargs: ABIArgType, - ) -> ABITransactionResponse: ... - - @overload - def create( - self, - call_abi_method: ABIMethod | bool | None = ..., - transaction_parameters: CreateCallParameters | CreateCallParametersDict | None = ..., - **abi_kwargs: ABIArgType, - ) -> TransactionResponse | ABITransactionResponse: ... - - def create( - self, - call_abi_method: ABIMethod | 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.compose_create( - atc, - call_abi_method, - transaction_parameters, - **abi_kwargs, - ) - create_result = self._execute_atc_tr(atc) - self.app_id = au_deploy.get_app_id_from_tx_id(self.algod_client, create_result.tx_id) - return create_result - - def compose_update( - self, - atc: AtomicTransactionComposer, - /, - call_abi_method: ABIMethod | bool | None = None, - transaction_parameters: TransactionParameters | TransactionParametersDict | 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.add_method_call( - atc=atc, - 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, - ) - - @overload - def update( - self, - call_abi_method: ABIMethod | Literal[True], - transaction_parameters: TransactionParameters | TransactionParametersDict | None = ..., - **abi_kwargs: ABIArgType, - ) -> ABITransactionResponse: ... - - @overload - def update( - self, - call_abi_method: Literal[False], - transaction_parameters: TransactionParameters | TransactionParametersDict | None = ..., - ) -> TransactionResponse: ... - - @overload - def update( - self, - call_abi_method: ABIMethod | bool | None = ..., - transaction_parameters: TransactionParameters | TransactionParametersDict | None = ..., - **abi_kwargs: ABIArgType, - ) -> TransactionResponse | ABITransactionResponse: ... - - def update( - self, - call_abi_method: ABIMethod | bool | None = None, - transaction_parameters: TransactionParameters | TransactionParametersDict | None = None, - **abi_kwargs: ABIArgType, - ) -> TransactionResponse | ABITransactionResponse: - """Submits a signed transaction with on_complete=UpdateApplication""" - - atc = AtomicTransactionComposer() - self.compose_update( - atc, - call_abi_method, - transaction_parameters=transaction_parameters, - **abi_kwargs, - ) - return self._execute_atc_tr(atc) - - def compose_delete( - self, - atc: AtomicTransactionComposer, - /, - call_abi_method: ABIMethod | bool | None = None, - transaction_parameters: TransactionParameters | TransactionParametersDict | None = None, - **abi_kwargs: ABIArgType, - ) -> None: - """Adds a signed transaction with on_complete=DeleteApplication to atc""" - - self.add_method_call( - atc, - call_abi_method, - abi_args=abi_kwargs, - parameters=transaction_parameters, - on_complete=transaction.OnComplete.DeleteApplicationOC, - ) - - @overload - def delete( - self, - call_abi_method: ABIMethod | Literal[True], - transaction_parameters: TransactionParameters | TransactionParametersDict | None = ..., - **abi_kwargs: ABIArgType, - ) -> ABITransactionResponse: ... - - @overload - def delete( - self, - call_abi_method: Literal[False], - transaction_parameters: TransactionParameters | TransactionParametersDict | None = ..., - ) -> TransactionResponse: ... - - @overload - def delete( - self, - call_abi_method: ABIMethod | bool | None = ..., - transaction_parameters: TransactionParameters | TransactionParametersDict | None = ..., - **abi_kwargs: ABIArgType, - ) -> TransactionResponse | ABITransactionResponse: ... - - def delete( - self, - call_abi_method: ABIMethod | bool | None = None, - transaction_parameters: TransactionParameters | TransactionParametersDict | None = None, - **abi_kwargs: ABIArgType, - ) -> TransactionResponse | ABITransactionResponse: - """Submits a signed transaction with on_complete=DeleteApplication""" - - atc = AtomicTransactionComposer() - self.compose_delete( - atc, - call_abi_method, - transaction_parameters=transaction_parameters, - **abi_kwargs, - ) - return self._execute_atc_tr(atc) - - def compose_call( - self, - atc: AtomicTransactionComposer, - /, - call_abi_method: ABIMethod | bool | None = None, - transaction_parameters: OnCompleteCallParameters | OnCompleteCallParametersDict | None = None, - **abi_kwargs: ABIArgType, - ) -> None: - """Adds a signed transaction with specified parameters to atc""" - _parameters = _convert_transaction_parameters(transaction_parameters) - self.add_method_call( - atc, - abi_method=call_abi_method, - abi_args=abi_kwargs, - parameters=_parameters, - on_complete=_parameters.on_complete or transaction.OnComplete.NoOpOC, - ) - - @overload - def call( - self, - call_abi_method: ABIMethod | Literal[True], - transaction_parameters: OnCompleteCallParameters | OnCompleteCallParametersDict | None = ..., - **abi_kwargs: ABIArgType, - ) -> ABITransactionResponse: ... - - @overload - def call( - self, - call_abi_method: Literal[False], - transaction_parameters: OnCompleteCallParameters | OnCompleteCallParametersDict | None = ..., - ) -> TransactionResponse: ... - - @overload - def call( - self, - call_abi_method: ABIMethod | bool | None = ..., - transaction_parameters: OnCompleteCallParameters | OnCompleteCallParametersDict | None = ..., - **abi_kwargs: ABIArgType, - ) -> TransactionResponse | ABITransactionResponse: ... - - def call( - self, - call_abi_method: ABIMethod | bool | None = None, - transaction_parameters: OnCompleteCallParameters | OnCompleteCallParametersDict | None = None, - **abi_kwargs: ABIArgType, - ) -> TransactionResponse | ABITransactionResponse: - """Submits a signed transaction with specified parameters""" - atc = AtomicTransactionComposer() - _parameters = _convert_transaction_parameters(transaction_parameters) - self.compose_call( - atc, - 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 method: - hints = self._method_hints(method) - if hints and hints.read_only: - if config.debug and config.project_root and config.trace_all: - simulate_and_persist_response( - atc, config.project_root, self.algod_client, config.trace_buffer_size_mb - ) - - return self._simulate_readonly_call(method, atc) - - return self._execute_atc_tr(atc) - - def compose_opt_in( - self, - atc: AtomicTransactionComposer, - /, - call_abi_method: ABIMethod | bool | None = None, - transaction_parameters: TransactionParameters | TransactionParametersDict | None = None, - **abi_kwargs: ABIArgType, - ) -> None: - """Adds a signed transaction with on_complete=OptIn to atc""" - self.add_method_call( - atc, - abi_method=call_abi_method, - abi_args=abi_kwargs, - parameters=transaction_parameters, - on_complete=transaction.OnComplete.OptInOC, - ) - - @overload - def opt_in( - self, - call_abi_method: ABIMethod | Literal[True] = ..., - transaction_parameters: TransactionParameters | TransactionParametersDict | None = None, - **abi_kwargs: ABIArgType, - ) -> ABITransactionResponse: ... - - @overload - def opt_in( - self, - call_abi_method: Literal[False] = ..., - transaction_parameters: TransactionParameters | TransactionParametersDict | None = None, - ) -> TransactionResponse: ... - - @overload - def opt_in( - self, - call_abi_method: ABIMethod | bool | None = ..., - transaction_parameters: TransactionParameters | TransactionParametersDict | None = ..., - **abi_kwargs: ABIArgType, - ) -> TransactionResponse | ABITransactionResponse: ... - - def opt_in( - self, - call_abi_method: ABIMethod | bool | None = None, - transaction_parameters: TransactionParameters | TransactionParametersDict | None = None, - **abi_kwargs: ABIArgType, - ) -> TransactionResponse | ABITransactionResponse: - """Submits a signed transaction with on_complete=OptIn""" - atc = AtomicTransactionComposer() - self.compose_opt_in( - atc, - 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, - /, - call_abi_method: ABIMethod | bool | None = None, - transaction_parameters: TransactionParameters | TransactionParametersDict | None = None, - **abi_kwargs: ABIArgType, - ) -> None: - """Adds a signed transaction with on_complete=CloseOut to ac""" - self.add_method_call( - atc, - abi_method=call_abi_method, - abi_args=abi_kwargs, - parameters=transaction_parameters, - on_complete=transaction.OnComplete.CloseOutOC, - ) - - @overload - def close_out( - self, - call_abi_method: ABIMethod | Literal[True], - transaction_parameters: TransactionParameters | TransactionParametersDict | None = ..., - **abi_kwargs: ABIArgType, - ) -> ABITransactionResponse: ... - - @overload - def close_out( - self, - call_abi_method: Literal[False], - transaction_parameters: TransactionParameters | TransactionParametersDict | None = ..., - ) -> TransactionResponse: ... - - @overload - def close_out( - self, - call_abi_method: ABIMethod | bool | None = ..., - transaction_parameters: TransactionParameters | TransactionParametersDict | None = ..., - **abi_kwargs: ABIArgType, - ) -> TransactionResponse | ABITransactionResponse: ... - - def close_out( - self, - call_abi_method: ABIMethod | bool | None = None, - transaction_parameters: TransactionParameters | TransactionParametersDict | None = None, - **abi_kwargs: ABIArgType, - ) -> TransactionResponse | ABITransactionResponse: - """Submits a signed transaction with on_complete=CloseOut""" - atc = AtomicTransactionComposer() - self.compose_close_out( - atc, - 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, - /, - transaction_parameters: TransactionParameters | TransactionParametersDict | None = None, - app_args: list[bytes] | None = None, - ) -> None: - """Adds a signed transaction with on_complete=ClearState to atc""" - return self.add_method_call( - atc, - parameters=transaction_parameters, - on_complete=transaction.OnComplete.ClearStateOC, - app_args=app_args, - ) - - def clear_state( - self, - transaction_parameters: TransactionParameters | TransactionParametersDict | None = None, - app_args: list[bytes] | None = None, - ) -> TransactionResponse: - """Submits a signed transaction with on_complete=ClearState""" - atc = AtomicTransactionComposer() - self.compose_clear_state( - atc, - 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 associated with 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), - ) - - def get_local_state(self, account: str | None = None, *, raw: bool = False) -> dict[bytes | str, bytes | str | int]: - """Gets the local state info for associated app_id and account/sender""" - - if account is None: - _, account = self.resolve_signer_sender(self.signer, self.sender) - - 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: au_spec.DefaultArgumentDict) -> int | str | bytes: - """Resolves the default value for an ABI method, based on app_spec""" - - def _data_check(value: object) -> int | str | bytes: - if isinstance(value, int | str | bytes): - return value - raise ValueError(f"Unexpected type for constant data: {value}") - - match to_resolve: - case {"source": "constant", "data": data}: - return _data_check(data) - case {"source": "global-state", "data": str() as key}: - global_state = self.get_global_state(raw=True) - return global_state[key.encode()] - case {"source": "local-state", "data": str() as key}: - _, sender = self.resolve_signer_sender(self.signer, self.sender) - acct_state = self.get_local_state(sender, raw=True) - return acct_state[key.encode()] - case {"source": "abi-method", "data": dict() as method_dict}: - method = Method.undictify(method_dict) - response = self.call(method) - assert isinstance(response, ABITransactionResponse) - return _data_check(response.return_value) - - case {"source": source}: - raise ValueError(f"Unrecognized default argument source: {source}") - case _: - raise TypeError("Unable to interpret default argument specification") - - def _get_app_deploy_metadata( - self, version: str | None, allow_update: bool | None, allow_delete: bool | None - ) -> au_deploy.AppDeployMetaData: - updatable = ( - allow_update - if allow_update is not None - 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 au_deploy.get_deploy_control( - self.app_spec, au_deploy.DELETABLE_TEMPLATE_NAME, transaction.OnComplete.DeleteApplicationOC - ) - ) - - app = self._load_app_reference() - - if version is None: - if app.app_id == 0: - version = "v1.0" - else: - assert isinstance(app, au_deploy.AppDeployMetaData) - version = get_next_version(app.version) - return au_deploy.AppDeployMetaData(self.app_name, version, updatable=updatable, deletable=deletable) - - def _check_is_compiled(self) -> tuple[Program, Program]: - if self._approval_program is None or self._clear_program is None: - self._approval_program, self._clear_program = substitute_template_and_compile( - self.algod_client, self.app_spec, self.template_values - ) - - if config.debug and config.project_root: - persist_sourcemaps( - sources=[ - PersistSourceMapInput( - compiled_teal=self._approval_program, app_name=self.app_name, file_name="approval.teal" - ), - PersistSourceMapInput( - compiled_teal=self._clear_program, app_name=self.app_name, file_name="clear.teal" - ), - ], - project_root=config.project_root, - client=self.algod_client, - with_sources=True, - ) - - return self._approval_program, self._clear_program - - def _simulate_readonly_call( - self, method: Method, atc: AtomicTransactionComposer - ) -> ABITransactionResponse | TransactionResponse: - response = simulate_response(atc, self.algod_client) - traces = None - if config.debug: - traces = _create_simulate_traces(response) - if response.failure_message: - raise _try_convert_to_logic_error( - response.failure_message, - self.app_spec.approval_program, - self._get_approval_source_map, - traces, - ) or Exception(f"Simulate failed for readonly method {method.get_signature()}: {response.failure_message}") - - return TransactionResponse.from_atr(response) - - def _load_reference_and_check_app_id(self) -> None: - self._load_app_reference() - self._check_app_id() - - 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 = au_deploy.get_creator_apps(self._indexer_client, self._creator) - - if self.existing_deployments: - app = self.existing_deployments.apps.get(self.app_name) - if app: - if self.app_id == 0: - self.app_id = app.app_id - return app - - return au_deploy.AppReference(self.app_id, self.app_address) - - def _check_app_id(self) -> None: - if self.app_id == 0: - raise Exception( - "ApplicationClient is not associated with an app instance, to resolve either:\n" - "1.) provide an app_id on construction OR\n" - "2.) provide a creator address so an app can be searched for OR\n" - "3.) create an app first using create or deploy methods" - ) - - def _resolve_method( - self, - abi_method: ABIMethod | bool | None, - args: ABIArgsDict | None, - on_complete: transaction.OnComplete, - call_config: au_spec.CallConfig = au_spec.CallConfig.CALL, - ) -> Method | None: - matches: list[Method | None] = [] - match abi_method: - case str() | Method(): # abi method specified - return self._resolve_abi_method(abi_method) - case bool() | None: # find abi method - has_bare_config = ( - 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) - if abi_method is not False: - matches += abi_methods - if has_bare_config and abi_method is not True: - matches += [None] - case _: - return abi_method.method_spec() - - if len(matches) == 1: # exact match - return matches[0] - 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.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.name} with call_config of {call_config.name}" - ) - - def _get_approval_source_map(self) -> SourceMap | None: - if self.approval_source_map: - return self.approval_source_map - - try: - approval, _ = self._check_is_compiled() - except au_deploy.DeploymentFailedError: - return None - return approval.source_map - - def export_source_map(self) -> str | None: - """Export approval source map to JSON, can be later re-imported with `import_source_map`""" - source_map = self._get_approval_source_map() - if source_map: - return json.dumps( - { - "version": source_map.version, - "sources": source_map.sources, - "mappings": source_map.mappings, - } - ) - return None - - def import_source_map(self, source_map_json: str) -> None: - """Import approval source from JSON exported by `export_source_map`""" - source_map = json.loads(source_map_json) - self._approval_source_map = SourceMap(source_map) - - def add_method_call( # noqa: PLR0913 - self, - atc: AtomicTransactionComposer, - abi_method: ABIMethod | bool | None = None, - *, - abi_args: ABIArgsDict | None = None, - app_id: int | None = None, - parameters: TransactionParameters | TransactionParametersDict | 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, - 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 - parameters = _convert_transaction_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 parameters.boxes] - else: - encoded_boxes = 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: - raise Exception(f"ABI arguments specified on a bare call: {', '.join(abi_args)}") - atc.add_transaction( - TransactionWithSigner( - txn=transaction.ApplicationCallTxn( # type: ignore[no-untyped-call] - sender=sender, - sp=sp, - index=app_id, - on_complete=on_complete, - approval_program=approval_program, - clear_program=clear_program, - global_schema=global_schema, - local_schema=local_schema, - extra_pages=extra_pages, - accounts=parameters.accounts, - foreign_apps=parameters.foreign_apps, - foreign_assets=parameters.foreign_assets, - boxes=encoded_boxes, - note=parameters.note, - lease=encoded_lease, - rekey_to=parameters.rekey_to, - app_args=app_args, - ), - signer=signer, - ) - ) - return - # resolve ABI method args - args = self._get_abi_method_args(abi_args, method) - atc.add_method_call( - app_id, - method, - sender, - sp, - signer, - method_args=args, - on_complete=on_complete, - local_schema=local_schema, - global_schema=global_schema, - approval_program=approval_program, - clear_program=clear_program, - extra_pages=extra_pages or 0, - accounts=parameters.accounts, - foreign_apps=parameters.foreign_apps, - foreign_assets=parameters.foreign_assets, - boxes=encoded_boxes, - note=parameters.note.encode("utf-8") if isinstance(parameters.note, str) else parameters.note, - lease=encoded_lease, - rekey_to=parameters.rekey_to, - ) - - def _get_abi_method_args(self, abi_args: ABIArgsDict | None, method: Method) -> list: - args: list = [] - hints = self._method_hints(method) - # copy args so we don't mutate original - abi_args = dict(abi_args or {}) - for method_arg in method.args: - name = method_arg.name - if name in abi_args: - argument = abi_args.pop(name) - if isinstance(argument, dict): - if hints.structs is None or name not in hints.structs: - raise Exception(f"Argument missing struct hint: {name}. Check argument name and type") - - elements = hints.structs[name]["elements"] - - argument_tuple = tuple(argument[field_name] for field_name, field_type in elements) - args.append(argument_tuple) - else: - args.append(argument) - - elif hints.default_arguments is not None and name in hints.default_arguments: - default_arg = hints.default_arguments[name] - if default_arg is not None: - args.append(self.resolve(default_arg)) - else: - raise Exception(f"Unspecified argument: {name}") - if abi_args: - raise Exception(f"Unused arguments specified: {', '.join(abi_args)}") - return args - - def _method_matches( - 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 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) - - # TODO: also match on types? - return method_args == provided_args - - def _find_abi_methods( - self, args: ABIArgsDict | None, on_complete: transaction.OnComplete, call_config: au_spec.CallConfig - ) -> list[Method]: - return [ - method - for method in self.app_spec.contract.methods - if self._method_matches(method, args, on_complete, call_config) - ] - - def _resolve_abi_method(self, method: ABIMethod) -> Method: - if isinstance(method, str): - try: - return next(iter(m for m in self.app_spec.contract.methods if m.get_signature() == method)) - except StopIteration: - pass - return self.app_spec.contract.get_method_by_name(method) - elif hasattr(method, "method_spec"): - return method.method_spec() - else: - return method - - def _method_hints(self, method: Method) -> au_spec.MethodHints: - sig = method.get_signature() - if sig not in self.app_spec.hints: - return au_spec.MethodHints() - return self.app_spec.hints[sig] - - def _execute_atc_tr(self, atc: AtomicTransactionComposer) -> TransactionResponse: - result = self.execute_atc(atc) - return TransactionResponse.from_atr(result) - - def execute_atc(self, atc: AtomicTransactionComposer) -> AtomicTransactionResponse: - return execute_atc_with_logic_error( - atc, - self.algod_client, - approval_program=self.app_spec.approval_program, - approval_source_map=self._get_approval_source_map, - ) - - def get_signer_sender( - self, signer: TransactionSigner | None = None, sender: str | None = None - ) -> tuple[TransactionSigner | None, str | None]: - """Return signer and sender, using default values on client if not specified - - Will use provided values if given, otherwise will fall back to values defined on client. - If no sender is specified then will attempt to obtain sender from signer""" - resolved_signer = signer or self.signer - resolved_sender = sender or get_sender_from_signer(signer) or self.sender or get_sender_from_signer(self.signer) - return resolved_signer, resolved_sender - - def resolve_signer_sender( - self, signer: TransactionSigner | None = None, sender: str | None = None - ) -> tuple[TransactionSigner, str]: - """Return signer and sender, using default values on client if not specified - - Will use provided values if given, otherwise will fall back to values defined on client. - If no sender is specified then will attempt to obtain sender from signer - - :raises ValueError: Raised if a signer or sender is not provided. See `get_signer_sender` - for variant with no exception""" - resolved_signer, resolved_sender = self.get_signer_sender(signer, sender) - if not resolved_signer: - raise ValueError("No signer provided") - if not resolved_sender: - raise ValueError("No sender provided") - return resolved_signer, resolved_sender - - # TODO: remove private implementation, kept in the 1.0.2 release to not impact existing beaker 1.0 installs - _resolve_signer_sender = resolve_signer_sender - - -def substitute_template_and_compile( - algod_client: "AlgodClient", - app_spec: au_spec.ApplicationSpecification, - template_values: au_deploy.TemplateValueMapping, -) -> tuple[Program, Program]: - """Substitutes the provided template_values into app_spec and compiles""" - 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) - - approval_app, clear_app = Program(approval, algod_client), Program(clear, algod_client) - - return approval_app, clear_app - - -def get_next_version(current_version: str) -> str: - """Calculates the next version from `current_version` - - Next version is calculated by finding a semver like - version string and incrementing the lower. This function is used by {py:meth}`ApplicationClient.deploy` when - a version is not specified, and is intended mostly for convenience during local development. - - :params str current_version: An existing version string with a semver like version contained within it, - some valid inputs and incremented outputs: - `1` -> `2` - `1.0` -> `1.1` - `v1.1` -> `v1.2` - `v1.1-beta1` -> `v1.2-beta1` - `v1.2.3.4567` -> `v1.2.3.4568` - `v1.2.3.4567-alpha` -> `v1.2.3.4568-alpha` - :raises DeploymentFailedError: If `current_version` cannot be parsed""" - pattern = re.compile(r"(?P\w*)(?P(?:\d+\.)*\d+)(?P\w*)") - match = pattern.match(current_version) - if match: - version = match.group("version") - new_version = _increment_version(version) - - def replacement(m: re.Match) -> str: - return f"{m.group('prefix')}{new_version}{m.group('suffix')}" - - return re.sub(pattern, replacement, current_version) - raise au_deploy.DeploymentFailedError( - f"Could not auto increment {current_version}, please specify the next version using the version parameter" - ) - - -def _try_convert_to_logic_error( - source_ex: Exception | str, - approval_program: str, - approval_source_map: SourceMap | typing.Callable[[], SourceMap | None] | None = None, - simulate_traces: list[SimulationTrace] | None = None, -) -> Exception | None: - source_ex_str = str(source_ex) - logic_error_data = parse_logic_error(source_ex_str) - if logic_error_data: - return LogicError( - logic_error_str=source_ex_str, - logic_error=source_ex if isinstance(source_ex, Exception) else None, - program=approval_program, - source_map=approval_source_map() if callable(approval_source_map) else approval_source_map, - **logic_error_data, - traces=simulate_traces, - ) - - return None - - -def execute_atc_with_logic_error( - atc: AtomicTransactionComposer, - algod_client: "AlgodClient", - approval_program: str, - wait_rounds: int = 4, - approval_source_map: SourceMap | typing.Callable[[], SourceMap | None] | None = None, -) -> AtomicTransactionResponse: - """Calls {py:meth}`AtomicTransactionComposer.execute` on provided `atc`, but will parse any errors - and raise a {py:class}`LogicError` if possible - - ```{note} - `approval_program` and `approval_source_map` are required to be able to parse any errors into a - {py:class}`LogicError` - ``` - """ - try: - if config.debug and config.project_root and config.trace_all: - simulate_and_persist_response(atc, config.project_root, algod_client, config.trace_buffer_size_mb) - - return atc.execute(algod_client, wait_rounds=wait_rounds) - except Exception as ex: - if config.debug: - simulate = None - if config.project_root and not config.trace_all: - # if trace_all is enabled, we already have the traces executed above - # hence we only need to simulate if trace_all is disabled and - # project_root is set - simulate = simulate_and_persist_response( - atc, config.project_root, algod_client, config.trace_buffer_size_mb - ) - else: - simulate = simulate_response(atc, algod_client) - traces = _create_simulate_traces(simulate) - else: - traces = None - logger.info("An error occurred while executing the transaction.") - logger.info("To see more details, enable debug mode by setting config.debug = True ") - - logic_error = _try_convert_to_logic_error(ex, approval_program, approval_source_map, traces) - if logic_error: - raise logic_error from ex - raise ex - - -def _create_simulate_traces(simulate: SimulateAtomicTransactionResponse) -> list[SimulationTrace]: - traces = [] - if hasattr(simulate, "simulate_response") and hasattr(simulate, "failed_at") and simulate.failed_at: - for txn_group in simulate.simulate_response["txn-groups"]: - app_budget_added = txn_group.get("app-budget-added", None) - app_budget_consumed = txn_group.get("app-budget-consumed", None) - failure_message = txn_group.get("failure-message", None) - txn_result = txn_group.get("txn-results", [{}])[0] - exec_trace = txn_result.get("exec-trace", {}) - traces.append( - SimulationTrace( - app_budget_added=app_budget_added, - app_budget_consumed=app_budget_consumed, - failure_message=failure_message, - exec_trace=exec_trace, - ) - ) - return traces - - -def _convert_transaction_parameters( - args: TransactionParameters | TransactionParametersDict | None, -) -> CreateCallParameters: - _args = args.__dict__ if isinstance(args, TransactionParameters) else (args or {}) - return CreateCallParameters(**_args) - - -def get_sender_from_signer(signer: TransactionSigner | None) -> str | None: - """Returns the associated address of a signer, return None if no address found""" - - if isinstance(signer, AccountTransactionSigner): - sender = address_from_private_key(signer.private_key) # type: ignore[no-untyped-call] - assert isinstance(sender, str) - return sender - elif isinstance(signer, MultisigTransactionSigner): - sender = signer.msig.address() # type: ignore[no-untyped-call] - assert isinstance(sender, str) - return sender - elif isinstance(signer, LogicSigTransactionSigner): - return signer.lsig.address() - return None - - -# TEMPORARY, use SDK one when available -def _parse_result( - methods: dict[int, Method], - txns: list[dict[str, Any]], - txids: list[str], -) -> list[ABIResult]: - method_results = [] - for i, tx_info in enumerate(txns): - raw_value = b"" - return_value = None - decode_error = None - - if i not in methods: - continue - - # Parse log for ABI method return value - try: - if methods[i].returns.type == Returns.VOID: - method_results.append( - ABIResult( - tx_id=txids[i], - raw_value=raw_value, - return_value=return_value, - decode_error=decode_error, - tx_info=tx_info, - method=methods[i], - ) - ) - continue - - logs = tx_info.get("logs", []) - - # Look for the last returned value in the log - if not logs: - raise Exception("No logs") - - result = logs[-1] - # Check that the first four bytes is the hash of "return" - result_bytes = base64.b64decode(result) - if len(result_bytes) < len(ABI_RETURN_HASH) or result_bytes[: len(ABI_RETURN_HASH)] != ABI_RETURN_HASH: - raise Exception("no logs") - - raw_value = result_bytes[4:] - abi_return_type = methods[i].returns.type - if isinstance(abi_return_type, ABIType): - return_value = abi_return_type.decode(raw_value) - else: - return_value = raw_value - - except Exception as e: - decode_error = e - - method_results.append( - ABIResult( - tx_id=txids[i], - raw_value=raw_value, - return_value=return_value, - decode_error=decode_error, - tx_info=tx_info, - method=methods[i], - ) - ) - - return method_results - - -def _increment_version(version: str) -> str: - split = list(map(int, version.split("."))) - split[-1] = split[-1] + 1 - return ".".join(str(x) for x in split) - - -def _str_or_hex(v: bytes) -> str: - decoded: str - try: - decoded = v.decode("utf-8") - except UnicodeDecodeError: - decoded = v.hex() - - return decoded - - -def _decode_state(state: list[dict[str, Any]], *, raw: bool = False) -> dict[str | bytes, bytes | str | int | None]: - decoded_state: dict[str | bytes, bytes | str | int | None] = {} - - for state_value in state: - raw_key = base64.b64decode(state_value["key"]) - - key: str | bytes = raw_key if raw else _str_or_hex(raw_key) - val: str | bytes | int | None - - action = state_value["value"]["action"] if "action" in state_value["value"] else state_value["value"]["type"] - - match action: - case 1: - raw_val = base64.b64decode(state_value["value"]["bytes"]) - val = raw_val if raw else _str_or_hex(raw_val) - case 2: - val = state_value["value"]["uint"] - case 3: - val = None - case _: - raise NotImplementedError - - decoded_state[key] = val - return decoded_state +from algokit_utils._legacy_v2.application_client import * # noqa: F403 diff --git a/src/algokit_utils/application_specification.py b/src/algokit_utils/application_specification.py index 392fce8d..56c286ee 100644 --- a/src/algokit_utils/application_specification.py +++ b/src/algokit_utils/application_specification.py @@ -1,206 +1 @@ -import base64 -import dataclasses -import json -from enum import IntFlag -from pathlib import Path -from typing import Any, Literal, TypeAlias, TypedDict - -from algosdk.abi import Contract -from algosdk.abi.method import MethodDict -from algosdk.transaction import StateSchema - -__all__ = [ - "CallConfig", - "DefaultArgumentDict", - "DefaultArgumentType", - "MethodConfigDict", - "OnCompleteActionName", - "MethodHints", - "ApplicationSpecification", - "AppSpecStateDict", -] - - -AppSpecStateDict: TypeAlias = dict[str, dict[str, dict]] -"""Type defining Application Specification state entries""" - - -class CallConfig(IntFlag): - """Describes the type of calls a method can be used for based on {py:class}`algosdk.transaction.OnComplete` type""" - - NEVER = 0 - """Never handle the specified on completion type""" - CALL = 1 - """Only handle the specified on completion type for application calls""" - CREATE = 2 - """Only handle the specified on completion type for application create calls""" - ALL = 3 - """Handle the specified on completion type for both create and normal application calls""" - - -class StructArgDict(TypedDict): - name: str - elements: list[list[str]] - - -OnCompleteActionName: TypeAlias = Literal[ - "no_op", "opt_in", "close_out", "clear_state", "update_application", "delete_application" -] -"""String literals representing on completion transaction types""" -MethodConfigDict: TypeAlias = dict[OnCompleteActionName, CallConfig] -"""Dictionary of `dict[OnCompletionActionName, CallConfig]` representing allowed actions for each on completion type""" -DefaultArgumentType: TypeAlias = Literal["abi-method", "local-state", "global-state", "constant"] -"""Literal values describing the types of default argument sources""" - - -class DefaultArgumentDict(TypedDict): - """ - DefaultArgument is a container for any arguments that may - be resolved prior to calling some target method - """ - - source: DefaultArgumentType - data: int | str | bytes | MethodDict - - -StateDict = TypedDict( # need to use function-form of TypedDict here since "global" is a reserved keyword - "StateDict", {"global": AppSpecStateDict, "local": AppSpecStateDict} -) - - -@dataclasses.dataclass(kw_only=True) -class MethodHints: - """MethodHints provides hints to the caller about how to call the method""" - - #: hint to indicate this method can be called through Dryrun - read_only: bool = False - #: hint to provide names for tuple argument indices - #: method_name=>param_name=>{name:str, elements:[str,str]} - structs: dict[str, StructArgDict] = dataclasses.field(default_factory=dict) - #: defaults - default_arguments: dict[str, DefaultArgumentDict] = dataclasses.field(default_factory=dict) - call_config: MethodConfigDict = dataclasses.field(default_factory=dict) - - def empty(self) -> bool: - return not self.dictify() - - def dictify(self) -> dict[str, Any]: - d: dict[str, Any] = {} - if self.read_only: - d["read_only"] = True - if self.default_arguments: - d["default_arguments"] = self.default_arguments - if self.structs: - d["structs"] = self.structs - if any(v for v in self.call_config.values() if v != CallConfig.NEVER): - d["call_config"] = _encode_method_config(self.call_config) - return d - - @staticmethod - def undictify(data: dict[str, Any]) -> "MethodHints": - return MethodHints( - read_only=data.get("read_only", False), - default_arguments=data.get("default_arguments", {}), - structs=data.get("structs", {}), - call_config=_decode_method_config(data.get("call_config", {})), - ) - - -def _encode_method_config(mc: MethodConfigDict) -> dict[str, str | None]: - return {k: mc[k].name for k in sorted(mc) if mc[k] != CallConfig.NEVER} - - -def _decode_method_config(data: dict[OnCompleteActionName, Any]) -> MethodConfigDict: - return {k: CallConfig[v] for k, v in data.items()} - - -def _encode_source(teal_text: str) -> str: - return base64.b64encode(teal_text.encode()).decode("utf-8") - - -def _decode_source(b64_text: str) -> str: - return base64.b64decode(b64_text).decode("utf-8") - - -def _encode_state_schema(schema: StateSchema) -> dict[str, int]: - return { - "num_byte_slices": schema.num_byte_slices, - "num_uints": schema.num_uints, - } - - -def _decode_state_schema(data: dict[str, int]) -> StateSchema: - return StateSchema( # type: ignore[no-untyped-call] - num_byte_slices=data.get("num_byte_slices", 0), - num_uints=data.get("num_uints", 0), - ) - - -@dataclasses.dataclass(kw_only=True) -class ApplicationSpecification: - """ARC-0032 application specification - - See """ - - approval_program: str - clear_program: str - contract: Contract - hints: dict[str, MethodHints] - schema: StateDict - global_state_schema: StateSchema - local_state_schema: StateSchema - bare_call_config: MethodConfigDict - - def dictify(self) -> dict: - return { - "hints": {k: v.dictify() for k, v in self.hints.items() if not v.empty()}, - "source": { - "approval": _encode_source(self.approval_program), - "clear": _encode_source(self.clear_program), - }, - "state": { - "global": _encode_state_schema(self.global_state_schema), - "local": _encode_state_schema(self.local_state_schema), - }, - "schema": self.schema, - "contract": self.contract.dictify(), - "bare_call_config": _encode_method_config(self.bare_call_config), - } - - def to_json(self) -> str: - return json.dumps(self.dictify(), indent=4) - - @staticmethod - def from_json(application_spec: str) -> "ApplicationSpecification": - json_spec = json.loads(application_spec) - return ApplicationSpecification( - approval_program=_decode_source(json_spec["source"]["approval"]), - clear_program=_decode_source(json_spec["source"]["clear"]), - schema=json_spec["schema"], - global_state_schema=_decode_state_schema(json_spec["state"]["global"]), - local_state_schema=_decode_state_schema(json_spec["state"]["local"]), - contract=Contract.undictify(json_spec["contract"]), - hints={k: MethodHints.undictify(v) for k, v in json_spec["hints"].items()}, - bare_call_config=_decode_method_config(json_spec.get("bare_call_config", {})), - ) - - def export(self, directory: Path | str | None = None) -> None: - """write out the artifacts generated by the application to disk - - Args: - directory(optional): path to the directory where the artifacts should be written - """ - if directory is None: - output_dir = Path.cwd() - else: - output_dir = Path(directory) - output_dir.mkdir(exist_ok=True, parents=True) - - (output_dir / "approval.teal").write_text(self.approval_program) - (output_dir / "clear.teal").write_text(self.clear_program) - (output_dir / "contract.json").write_text(json.dumps(self.contract.dictify(), indent=4)) - (output_dir / "application.json").write_text(self.to_json()) - - -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] +from algokit_utils._legacy_v2.application_specification import * # noqa: F403 diff --git a/src/algokit_utils/applications/__init__.py b/src/algokit_utils/applications/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/algokit_utils/applications/models.py b/src/algokit_utils/applications/models.py new file mode 100644 index 00000000..e69de29b diff --git a/src/algokit_utils/asset.py b/src/algokit_utils/asset.py index 085ea8c5..4d9c8522 100644 --- a/src/algokit_utils/asset.py +++ b/src/algokit_utils/asset.py @@ -1,168 +1 @@ -import logging -from typing import TYPE_CHECKING - -from algosdk.atomic_transaction_composer import AtomicTransactionComposer, TransactionWithSigner -from algosdk.constants import TX_GROUP_LIMIT -from algosdk.transaction import AssetTransferTxn - -if TYPE_CHECKING: - from algosdk.v2client.algod import AlgodClient - -from enum import Enum, auto - -from algokit_utils.models import Account - -__all__ = ["opt_in", "opt_out"] -logger = logging.getLogger(__name__) - - -class ValidationType(Enum): - OPTIN = auto() - OPTOUT = auto() - - -def _ensure_account_is_valid(algod_client: "AlgodClient", account: Account) -> None: - try: - algod_client.account_info(account.address) - except Exception as err: - error_message = f"Account address{account.address} does not exist" - logger.debug(error_message) - raise err - - -def _ensure_asset_balance_conditions( - algod_client: "AlgodClient", account: Account, asset_ids: list, validation_type: ValidationType -) -> None: - invalid_asset_ids = [] - account_info = algod_client.account_info(account.address) - account_assets = account_info.get("assets", []) # type: ignore # noqa: PGH003 - for asset_id in asset_ids: - asset_exists_in_account_info = any(asset["asset-id"] == asset_id for asset in account_assets) - if validation_type == ValidationType.OPTIN: - if asset_exists_in_account_info: - logger.debug(f"Asset {asset_id} is already opted in for account {account.address}") - invalid_asset_ids.append(asset_id) - - elif validation_type == ValidationType.OPTOUT: - if not account_assets or not asset_exists_in_account_info: - logger.debug(f"Account {account.address} does not have asset {asset_id}") - invalid_asset_ids.append(asset_id) - else: - asset_balance = next((asset["amount"] for asset in account_assets if asset["asset-id"] == asset_id), 0) - if asset_balance != 0: - logger.debug(f"Asset {asset_id} balance is not zero") - invalid_asset_ids.append(asset_id) - - if len(invalid_asset_ids) > 0: - action = "opted out" if validation_type == ValidationType.OPTOUT else "opted in" - condition_message = ( - "their amount is zero and that the account has" - if validation_type == ValidationType.OPTOUT - else "they are valid and that the account has not" - ) - - error_message = ( - f"Assets {invalid_asset_ids} cannot be {action}. Ensure that " - f"{condition_message} previously opted into them." - ) - raise ValueError(error_message) - - -def opt_in(algod_client: "AlgodClient", account: Account, asset_ids: list[int]) -> dict[int, str]: - """ - Opt-in to a list of assets on the Algorand blockchain. Before an account can receive a specific asset, - it must `opt-in` to receive it. An opt-in transaction places an asset holding of 0 into the account and increases - its minimum balance by [100,000 microAlgos](https://developer.algorand.org/docs/get-details/asa/#assets-overview). - - Args: - algod_client (AlgodClient): An instance of the AlgodClient class from the algosdk library. - account (Account): An instance of the Account class representing the account that wants to opt-in to the assets. - asset_ids (list[int]): A list of integers representing the asset IDs to opt-in to. - Returns: - dict[int, str]: A dictionary where the keys are the asset IDs and the values - are the transaction IDs for opting-in to each asset. - """ - _ensure_account_is_valid(algod_client, account) - _ensure_asset_balance_conditions(algod_client, account, asset_ids, ValidationType.OPTIN) - suggested_params = algod_client.suggested_params() - result = {} - for i in range(0, len(asset_ids), TX_GROUP_LIMIT): - atc = AtomicTransactionComposer() - chunk = asset_ids[i : i + TX_GROUP_LIMIT] - for asset_id in chunk: - asset = algod_client.asset_info(asset_id) - xfer_txn = AssetTransferTxn( - sp=suggested_params, - sender=account.address, - receiver=account.address, - close_assets_to=None, - revocation_target=None, - amt=0, - note=f"opt in asset id ${asset_id}", - index=asset["index"], # type: ignore # noqa: PGH003 - rekey_to=None, - ) - - transaction_with_signer = TransactionWithSigner( - txn=xfer_txn, - signer=account.signer, - ) - atc.add_transaction(transaction_with_signer) - atc.execute(algod_client, 4) - - for index, asset_id in enumerate(chunk): - result[asset_id] = atc.tx_ids[index] - - return result - - -def opt_out(algod_client: "AlgodClient", account: Account, asset_ids: list[int]) -> dict[int, str]: - """ - Opt out from a list of Algorand Standard Assets (ASAs) by transferring them back to their creators. - The account also recovers the Minimum Balance Requirement for the asset (100,000 microAlgos) - The `optOut` function manages the opt-out process, permitting the account to discontinue holding a group of assets. - - It's essential to note that an account can only opt_out of an asset if its balance of that asset is zero. - - Args: - algod_client (AlgodClient): An instance of the AlgodClient class from the `algosdk` library. - account (Account): An instance of the Account class that holds the private key and address for an account. - asset_ids (list[int]): A list of integers representing the asset IDs of the ASAs to opt out from. - Returns: - dict[int, str]: A dictionary where the keys are the asset IDs and the values are the transaction IDs of - the executed transactions. - - """ - _ensure_account_is_valid(algod_client, account) - _ensure_asset_balance_conditions(algod_client, account, asset_ids, ValidationType.OPTOUT) - suggested_params = algod_client.suggested_params() - result = {} - for i in range(0, len(asset_ids), TX_GROUP_LIMIT): - atc = AtomicTransactionComposer() - chunk = asset_ids[i : i + TX_GROUP_LIMIT] - for asset_id in chunk: - asset = algod_client.asset_info(asset_id) - asset_creator = asset["params"]["creator"] # type: ignore # noqa: PGH003 - xfer_txn = AssetTransferTxn( - sp=suggested_params, - sender=account.address, - receiver=account.address, - close_assets_to=asset_creator, - revocation_target=None, - amt=0, - note=f"opt out asset id ${asset_id}", - index=asset["index"], # type: ignore # noqa: PGH003 - rekey_to=None, - ) - - transaction_with_signer = TransactionWithSigner( - txn=xfer_txn, - signer=account.signer, - ) - atc.add_transaction(transaction_with_signer) - atc.execute(algod_client, 4) - - for index, asset_id in enumerate(chunk): - result[asset_id] = atc.tx_ids[index] - - return result +from algokit_utils._legacy_v2.asset import * # noqa: F403 diff --git a/src/algokit_utils/assets/__init__.py b/src/algokit_utils/assets/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/algokit_utils/assets/models.py b/src/algokit_utils/assets/models.py new file mode 100644 index 00000000..e69de29b diff --git a/src/algokit_utils/clients/__init__.py b/src/algokit_utils/clients/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/algokit_utils/beta/algorand_client.py b/src/algokit_utils/clients/algorand_client.py similarity index 96% rename from src/algokit_utils/beta/algorand_client.py rename to src/algokit_utils/clients/algorand_client.py index e80dadaf..7e02e20a 100644 --- a/src/algokit_utils/beta/algorand_client.py +++ b/src/algokit_utils/clients/algorand_client.py @@ -4,10 +4,21 @@ from dataclasses import dataclass from typing import Any -from algokit_utils.beta.account_manager import AccountManager -from algokit_utils.beta.client_manager import AlgoSdkClients, ClientManager -from algokit_utils.beta.composer import ( - AlgokitComposer, +from algosdk.atomic_transaction_composer import AtomicTransactionResponse, TransactionSigner +from algosdk.transaction import SuggestedParams, Transaction, wait_for_confirmation +from typing_extensions import Self + +from algokit_utils.accounts.account_manager import AccountManager +from algokit_utils.clients.client_manager import AlgoSdkClients, ClientManager +from algokit_utils.network_clients import ( + AlgoClientConfigs, + get_algod_client, + get_algonode_config, + get_default_localnet_config, + get_indexer_client, + get_kmd_client, +) +from algokit_utils.transactions.transaction_composer import ( AppCallParams, AssetConfigParams, AssetCreateParams, @@ -18,18 +29,8 @@ MethodCallParams, OnlineKeyRegParams, PayParams, + TransactionComposer, ) -from algokit_utils.network_clients import ( - AlgoClientConfigs, - get_algod_client, - get_algonode_config, - get_default_localnet_config, - get_indexer_client, - get_kmd_client, -) -from algosdk.atomic_transaction_composer import AtomicTransactionResponse, TransactionSigner -from algosdk.transaction import SuggestedParams, Transaction, wait_for_confirmation -from typing_extensions import Self __all__ = [ "AlgorandClient", @@ -176,9 +177,9 @@ def account(self) -> AccountManager: """Get or create accounts that can sign transactions.""" return self._account_manager - def new_group(self) -> AlgokitComposer: - """Start a new `AlgokitComposer` transaction group""" - return AlgokitComposer( + def new_group(self) -> TransactionComposer: + """Start a new `TransactionComposer` transaction group""" + return TransactionComposer( algod=self.client.algod, get_signer=lambda addr: self.account.get_signer(addr), get_suggested_params=self.get_suggested_params, diff --git a/src/algokit_utils/beta/client_manager.py b/src/algokit_utils/clients/client_manager.py similarity index 73% rename from src/algokit_utils/beta/client_manager.py rename to src/algokit_utils/clients/client_manager.py index 1069eacf..16108520 100644 --- a/src/algokit_utils/beta/client_manager.py +++ b/src/algokit_utils/clients/client_manager.py @@ -1,21 +1,18 @@ import algosdk -from algokit_utils.dispenser_api import TestNetDispenserApiClient -from algokit_utils.network_clients import AlgoClientConfigs, get_algod_client, get_indexer_client, get_kmd_client from algosdk.kmd import KMDClient from algosdk.v2client.algod import AlgodClient from algosdk.v2client.indexer import IndexerClient +from algokit_utils.clients.dispenser_api_client import TestNetDispenserApiClient +from algokit_utils.network_clients import ( + AlgoClientConfigs, + get_algod_client, + get_indexer_client, + get_kmd_client, +) -class AlgoSdkClients: - """ - Clients from algosdk that interact with the official Algorand APIs. - - Attributes: - algod (AlgodClient): Algod client, see https://developer.algorand.org/docs/rest-apis/algod/ - indexer (Optional[IndexerClient]): Optional indexer client, see https://developer.algorand.org/docs/rest-apis/indexer/ - kmd (Optional[KMDClient]): Optional KMD client, see https://developer.algorand.org/docs/rest-apis/kmd/ - """ +class AlgoSdkClients: def __init__( self, algod: algosdk.v2client.algod.AlgodClient, @@ -28,13 +25,6 @@ def __init__( class ClientManager: - """ - Exposes access to various API clients. - - Args: - clients_or_config (Union[AlgoConfig, AlgoSdkClients]): algosdk clients or config for interacting with the official Algorand APIs. - """ - def __init__(self, clients_or_configs: AlgoClientConfigs | AlgoSdkClients): if isinstance(clients_or_configs, AlgoSdkClients): _clients = clients_or_configs diff --git a/src/algokit_utils/clients/dispenser_api_client.py b/src/algokit_utils/clients/dispenser_api_client.py new file mode 100644 index 00000000..66593e80 --- /dev/null +++ b/src/algokit_utils/clients/dispenser_api_client.py @@ -0,0 +1,178 @@ +import contextlib +import enum +import logging +import os +from dataclasses import dataclass + +import httpx + +logger = logging.getLogger(__name__) + + +class DispenserApiConfig: + BASE_URL = "https://api.dispenser.algorandfoundation.tools" + + +class DispenserAssetName(enum.IntEnum): + ALGO = 0 + + +@dataclass +class DispenserAsset: + asset_id: int + decimals: int + description: str + + +@dataclass +class DispenserFundResponse: + tx_id: str + amount: int + + +@dataclass +class DispenserLimitResponse: + amount: int + + +DISPENSER_ASSETS = { + DispenserAssetName.ALGO: DispenserAsset( + asset_id=0, + decimals=6, + description="Algo", + ), +} +DISPENSER_REQUEST_TIMEOUT = 15 +DISPENSER_ACCESS_TOKEN_KEY = "ALGOKIT_DISPENSER_ACCESS_TOKEN" + + +class TestNetDispenserApiClient: + """ + Client for interacting with the [AlgoKit TestNet Dispenser API](https://github.com/algorandfoundation/algokit/blob/main/docs/testnet_api.md). + To get started create a new access token via `algokit dispenser login --ci` + and pass it to the client constructor as `auth_token`. + Alternatively set the access token as environment variable `ALGOKIT_DISPENSER_ACCESS_TOKEN`, + and it will be auto loaded. If both are set, the constructor argument takes precedence. + + Default request timeout is 15 seconds. Modify by passing `request_timeout` to the constructor. + """ + + auth_token: str + request_timeout = DISPENSER_REQUEST_TIMEOUT + + def __init__(self, auth_token: str | None = None, request_timeout: int = DISPENSER_REQUEST_TIMEOUT): + auth_token_from_env = os.getenv(DISPENSER_ACCESS_TOKEN_KEY) + + if auth_token: + self.auth_token = auth_token + elif auth_token_from_env: + self.auth_token = auth_token_from_env + else: + raise Exception( + f"Can't init AlgoKit TestNet Dispenser API client " + f"because neither environment variable {DISPENSER_ACCESS_TOKEN_KEY} or " + "the auth_token were provided." + ) + + self.request_timeout = request_timeout + + def _process_dispenser_request( + self, *, auth_token: str, url_suffix: str, data: dict | None = None, method: str = "POST" + ) -> httpx.Response: + """ + Generalized method to process http requests to dispenser API + """ + + headers = {"Authorization": f"Bearer {(auth_token)}"} + + # Set request arguments + request_args = { + "url": f"{DispenserApiConfig.BASE_URL}/{url_suffix}", + "headers": headers, + "timeout": self.request_timeout, + } + + if method.upper() != "GET" and data is not None: + request_args["json"] = data + + try: + response: httpx.Response = getattr(httpx, method.lower())(**request_args) + response.raise_for_status() + return response + + except httpx.HTTPStatusError as err: + error_message = f"Error processing dispenser API request: {err.response.status_code}" + error_response = None + with contextlib.suppress(Exception): + error_response = err.response.json() + + if error_response and error_response.get("code"): + error_message = error_response.get("code") + + elif err.response.status_code == httpx.codes.BAD_REQUEST: + error_message = err.response.json()["message"] + + raise Exception(error_message) from err + + except Exception as err: + error_message = "Error processing dispenser API request" + logger.debug(f"{error_message}: {err}", exc_info=True) + raise err + + def fund(self, address: str, amount: int, asset_id: int) -> DispenserFundResponse: + """ + Fund an account with Algos from the dispenser API + """ + + try: + response = self._process_dispenser_request( + auth_token=self.auth_token, + url_suffix=f"fund/{asset_id}", + data={"receiver": address, "amount": amount, "assetID": asset_id}, + method="POST", + ) + + content = response.json() + return DispenserFundResponse(tx_id=content["txID"], amount=content["amount"]) + + except Exception as err: + logger.exception(f"Error funding account {address}: {err}") + raise err + + def refund(self, refund_txn_id: str) -> None: + """ + Register a refund for a transaction with the dispenser API + """ + + try: + self._process_dispenser_request( + auth_token=self.auth_token, + url_suffix="refund", + data={"refundTransactionID": refund_txn_id}, + method="POST", + ) + + except Exception as err: + logger.exception(f"Error issuing refund for txn_id {refund_txn_id}: {err}") + raise err + + def get_limit( + self, + address: str, + ) -> DispenserLimitResponse: + """ + Get current limit for an account with Algos from the dispenser API + """ + + try: + response = self._process_dispenser_request( + auth_token=self.auth_token, + url_suffix=f"fund/{DISPENSER_ASSETS[DispenserAssetName.ALGO].asset_id}/limit", + method="GET", + ) + content = response.json() + + return DispenserLimitResponse(amount=content["amount"]) + except Exception as err: + logger.exception(f"Error setting limit for account {address}: {err}") + raise err diff --git a/src/algokit_utils/clients/models.py b/src/algokit_utils/clients/models.py new file mode 100644 index 00000000..e69de29b diff --git a/src/algokit_utils/common.py b/src/algokit_utils/common.py index 8071c98f..45c54a87 100644 --- a/src/algokit_utils/common.py +++ b/src/algokit_utils/common.py @@ -1,28 +1 @@ -""" -This module contains common classes and methods that are reused in more than one file. -""" - -import base64 -import typing - -from algosdk.source_map import SourceMap - -from algokit_utils import deploy - -if typing.TYPE_CHECKING: - from algosdk.v2client.algod import AlgodClient - - -class Program: - """A compiled TEAL program""" - - def __init__(self, program: str, client: "AlgodClient"): - """ - Fully compile the program source to binary and generate a - source map for matching pc to line number - """ - self.teal = program - result: dict = client.compile(deploy.strip_comments(self.teal), source_map=True) - self.raw_binary = base64.b64decode(result["result"]) - self.binary_hash: str = result["hash"] - self.source_map = SourceMap(result["sourcemap"]) +from algokit_utils._legacy_v2.common import * # noqa: F403 diff --git a/src/algokit_utils/deploy.py b/src/algokit_utils/deploy.py index bb01c4f2..7543c6c1 100644 --- a/src/algokit_utils/deploy.py +++ b/src/algokit_utils/deploy.py @@ -1,897 +1 @@ -import base64 -import dataclasses -import json -import logging -import re -from collections.abc import Iterable, Mapping, Sequence -from enum import Enum -from typing import TYPE_CHECKING, TypeAlias, TypedDict - -from algosdk import transaction -from algosdk.atomic_transaction_composer import AtomicTransactionComposer, TransactionSigner -from algosdk.logic import get_application_address -from algosdk.transaction import StateSchema - -from algokit_utils.application_specification import ( - ApplicationSpecification, - CallConfig, - MethodConfigDict, - OnCompleteActionName, -) -from algokit_utils.models import ( - ABIArgsDict, - ABIMethod, - Account, - CreateCallParameters, - TransactionResponse, -) - -if TYPE_CHECKING: - from algosdk.v2client.algod import AlgodClient - from algosdk.v2client.indexer import IndexerClient - - from algokit_utils.application_client import ApplicationClient - - -__all__ = [ - "UPDATABLE_TEMPLATE_NAME", - "DELETABLE_TEMPLATE_NAME", - "NOTE_PREFIX", - "ABICallArgs", - "ABICreateCallArgs", - "ABICallArgsDict", - "ABICreateCallArgsDict", - "DeploymentFailedError", - "AppReference", - "AppDeployMetaData", - "AppMetaData", - "AppLookup", - "DeployCallArgs", - "DeployCreateCallArgs", - "DeployCallArgsDict", - "DeployCreateCallArgsDict", - "Deployer", - "DeployResponse", - "OnUpdate", - "OnSchemaBreak", - "OperationPerformed", - "TemplateValueDict", - "TemplateValueMapping", - "get_app_id_from_tx_id", - "get_creator_apps", - "replace_template_variables", -] - -logger = logging.getLogger(__name__) - -DEFAULT_INDEXER_MAX_API_RESOURCES_PER_ACCOUNT = 1000 -_UPDATABLE = "UPDATABLE" -_DELETABLE = "DELETABLE" -UPDATABLE_TEMPLATE_NAME = f"TMPL_{_UPDATABLE}" -"""Template variable name used to control if a smart contract is updatable or not at deployment""" -DELETABLE_TEMPLATE_NAME = f"TMPL_{_DELETABLE}" -"""Template variable name used to control if a smart contract is deletable or not at deployment""" -_TOKEN_PATTERN = re.compile(r"TMPL_[A-Z_]+") -TemplateValue: TypeAlias = int | str | bytes -TemplateValueDict: TypeAlias = dict[str, TemplateValue] -"""Dictionary of `dict[str, int | str | bytes]` representing template variable names and values""" -TemplateValueMapping: TypeAlias = Mapping[str, TemplateValue] -"""Mapping of `str` to `int | str | bytes` representing template variable names and values""" - -NOTE_PREFIX = "ALGOKIT_DEPLOYER:j" -"""ARC-0002 compliant note prefix for algokit_utils deployed applications""" -# This prefix is also used to filter for parsable transaction notes in get_creator_apps. -# However, as the note is base64 encoded first we need to consider it's base64 representation. -# When base64 encoding bytes, 3 bytes are stored in every 4 characters. -# So then we don't need to worry about the padding/changing characters of the prefix if it was followed by -# additional characters, assert the NOTE_PREFIX length is a multiple of 3. -assert len(NOTE_PREFIX) % 3 == 0 - - -class DeploymentFailedError(Exception): - pass - - -@dataclasses.dataclass -class AppReference: - """Information about an Algorand app""" - - app_id: int - app_address: str - - -@dataclasses.dataclass -class AppDeployMetaData: - """Metadata about an application stored in a transaction note during creation. - - The note is serialized as JSON and prefixed with {py:data}`NOTE_PREFIX` and stored in the transaction note field - as part of {py:meth}`ApplicationClient.deploy` - """ - - name: str - version: str - deletable: bool | None - updatable: bool | None - - @staticmethod - def from_json(value: str) -> "AppDeployMetaData": - json_value: dict = json.loads(value) - json_value.setdefault("deletable", None) - json_value.setdefault("updatable", None) - return AppDeployMetaData(**json_value) - - @classmethod - def from_b64(cls: type["AppDeployMetaData"], b64: str) -> "AppDeployMetaData": - return cls.decode(base64.b64decode(b64)) - - @classmethod - def decode(cls: type["AppDeployMetaData"], value: bytes) -> "AppDeployMetaData": - note = value.decode("utf-8") - assert note.startswith(NOTE_PREFIX) - return cls.from_json(note[len(NOTE_PREFIX) :]) - - def encode(self) -> bytes: - json_str = json.dumps(self.__dict__) - return f"{NOTE_PREFIX}{json_str}".encode() - - -@dataclasses.dataclass -class AppMetaData(AppReference, AppDeployMetaData): - """Metadata about a deployed app""" - - created_round: int - updated_round: int - created_metadata: AppDeployMetaData - deleted: bool - - -@dataclasses.dataclass -class AppLookup: - """Cache of {py:class}`AppMetaData` for a specific `creator` - - Can be used as an argument to {py:class}`ApplicationClient` to reduce the number of calls when deploying multiple - apps or discovering multiple app_ids - """ - - creator: str - apps: dict[str, AppMetaData] = dataclasses.field(default_factory=dict) - - -def _sort_by_round(txn: dict) -> tuple[int, int]: - confirmed = txn["confirmed-round"] - offset = txn["intra-round-offset"] - return confirmed, offset - - -def _parse_note(metadata_b64: str | None) -> AppDeployMetaData | None: - if not metadata_b64: - return None - # noinspection PyBroadException - try: - return AppDeployMetaData.from_b64(metadata_b64) - except Exception: - return None - - -def get_creator_apps(indexer: "IndexerClient", creator_account: Account | str) -> AppLookup: - """Returns a mapping of Application names to {py:class}`AppMetaData` for all Applications created by specified - creator that have a transaction note containing {py:class}`AppDeployMetaData` - """ - apps: dict[str, AppMetaData] = {} - - creator_address = creator_account if isinstance(creator_account, str) else creator_account.address - token = None - # TODO: paginated indexer call instead of N + 1 calls - while True: - response = indexer.lookup_account_application_by_creator( - creator_address, limit=DEFAULT_INDEXER_MAX_API_RESOURCES_PER_ACCOUNT, next_page=token - ) # type: ignore[no-untyped-call] - if "message" in response: # an error occurred - raise Exception(f"Error querying applications for {creator_address}: {response}") - for app in response["applications"]: - app_id = app["id"] - app_created_at_round = app["created-at-round"] - app_deleted = app.get("deleted", False) - search_transactions_response = indexer.search_transactions( - min_round=app_created_at_round, - txn_type="appl", - application_id=app_id, - address=creator_address, - address_role="sender", - note_prefix=NOTE_PREFIX.encode("utf-8"), - ) # type: ignore[no-untyped-call] - transactions: list[dict] = search_transactions_response["transactions"] - if not transactions: - continue - - created_transaction = next( - t - for t in transactions - if t["application-transaction"]["application-id"] == 0 and t["sender"] == creator_address - ) - - transactions.sort(key=_sort_by_round, reverse=True) - latest_transaction = transactions[0] - app_updated_at_round = latest_transaction["confirmed-round"] - - create_metadata = _parse_note(created_transaction.get("note")) - update_metadata = _parse_note(latest_transaction.get("note")) - - if create_metadata and create_metadata.name: - apps[create_metadata.name] = AppMetaData( - app_id=app_id, - app_address=get_application_address(app_id), - created_metadata=create_metadata, - created_round=app_created_at_round, - **(update_metadata or create_metadata).__dict__, - updated_round=app_updated_at_round, - deleted=app_deleted, - ) - - token = response.get("next-token") - if not token: - break - - return AppLookup(creator_address, apps) - - -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 _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}" - - -@dataclasses.dataclass(kw_only=True) -class AppChanges: - app_updated: bool - schema_breaking_change: bool - schema_change_description: str | None - - -def check_for_app_changes( # noqa: PLR0913 - 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), - ) - - -def _is_valid_token_character(char: str) -> bool: - return char.isalnum() or char == "_" - - -def _replace_template_variable(program_lines: list[str], template_variable: str, value: str) -> tuple[list[str], int]: - result: list[str] = [] - match_count = 0 - token = f"TMPL_{template_variable}" - token_idx_offset = len(value) - len(token) - for line in program_lines: - comment_idx = _find_unquoted_string(line, "//") - if comment_idx is None: - comment_idx = len(line) - code = line[:comment_idx] - comment = line[comment_idx:] - trailing_idx = 0 - while True: - token_idx = _find_template_token(code, token, trailing_idx) - if token_idx is None: - break - - trailing_idx = token_idx + len(token) - prefix = code[:token_idx] - suffix = code[trailing_idx:] - code = f"{prefix}{value}{suffix}" - match_count += 1 - trailing_idx += token_idx_offset - result.append(code + comment) - return result, match_count - - -def add_deploy_template_variables( - template_values: TemplateValueDict, allow_update: bool | None, allow_delete: bool | None -) -> None: - if allow_update is not None: - template_values[_UPDATABLE] = int(allow_update) - if allow_delete is not None: - template_values[_DELETABLE] = int(allow_delete) - - -def _find_unquoted_string(line: str, token: str, start: int = 0, end: int = -1) -> int | None: - """Find the first string within a line of TEAL. Only matches outside of quotes and base64 are returned. - Returns None if not found""" - - if end < 0: - end = len(line) - idx = start - in_quotes = in_base64 = False - while idx < end: - current_char = line[idx] - match current_char: - # enter base64 - case " " | "(" if not in_quotes and _last_token_base64(line, idx): - in_base64 = True - # exit base64 - case " " | ")" if not in_quotes and in_base64: - in_base64 = False - # escaped char - case "\\" if in_quotes: - # skip next character - idx += 1 - # quote boundary - case '"': - in_quotes = not in_quotes - # can test for match - case _ if not in_quotes and not in_base64 and line.startswith(token, idx): - # only match if not in quotes and string matches - return idx - idx += 1 - return None - - -def _last_token_base64(line: str, idx: int) -> bool: - try: - *_, last = line[:idx].split() - except ValueError: - return False - return last in ("base64", "b64") - - -def _find_template_token(line: str, token: str, start: int = 0, end: int = -1) -> int | None: - """Find the first template token within a line of TEAL. Only matches outside of quotes are returned. - Only full token matches are returned, i.e. TMPL_STR will not match against TMPL_STRING - Returns None if not found""" - if end < 0: - end = len(line) - - idx = start - while idx < end: - token_idx = _find_unquoted_string(line, token, idx, end) - if token_idx is None: - break - trailing_idx = token_idx + len(token) - if (token_idx == 0 or not _is_valid_token_character(line[token_idx - 1])) and ( # word boundary at start - trailing_idx >= len(line) or not _is_valid_token_character(line[trailing_idx]) # word boundary at end - ): - return token_idx - idx = trailing_idx - return None - - -def _strip_comment(line: str) -> str: - comment_idx = _find_unquoted_string(line, "//") - if comment_idx is None: - return line - return line[:comment_idx].rstrip() - - -def strip_comments(program: str) -> str: - return "\n".join(_strip_comment(line) for line in program.splitlines()) - - -def _has_token(program_without_comments: str, token: str) -> bool: - for line in program_without_comments.splitlines(): - token_idx = _find_template_token(line, token) - if token_idx is not None: - return True - return False - - -def _find_tokens(stripped_approval_program: str) -> list[str]: - return _TOKEN_PATTERN.findall(stripped_approval_program) - - -def check_template_variables(approval_program: str, template_values: TemplateValueDict) -> None: - approval_program = strip_comments(approval_program) - if _has_token(approval_program, UPDATABLE_TEMPLATE_NAME) and _UPDATABLE not in template_values: - raise DeploymentFailedError( - "allow_update must be specified if deploy time configuration of update is being used" - ) - if _has_token(approval_program, DELETABLE_TEMPLATE_NAME) and _DELETABLE not in template_values: - raise DeploymentFailedError( - "allow_delete must be specified if deploy time configuration of delete is being used" - ) - all_tokens = _find_tokens(approval_program) - missing_values = [token for token in all_tokens if token[len("TMPL_") :] not in template_values] - if missing_values: - raise DeploymentFailedError(f"The following template values were not provided: {', '.join(missing_values)}") - - for template_variable_name in template_values: - tmpl_variable = f"TMPL_{template_variable_name}" - if not _has_token(approval_program, tmpl_variable): - if template_variable_name == _UPDATABLE: - raise DeploymentFailedError( - "allow_update must only be specified if deploy time configuration of update is being used" - ) - if template_variable_name == _DELETABLE: - raise DeploymentFailedError( - "allow_delete must only be specified if deploy time configuration of delete is being used" - ) - logger.warning(f"{tmpl_variable} not found in approval program, but variable was provided") - - -def replace_template_variables(program: str, template_values: TemplateValueMapping) -> str: - """Replaces `TMPL_*` variables in `program` with `template_values` - - ```{note} - `template_values` keys should *NOT* be prefixed with `TMPL_` - ``` - """ - program_lines = program.splitlines() - for template_variable_name, template_value in template_values.items(): - match template_value: - case int(): - value = str(template_value) - case str(): - value = "0x" + template_value.encode("utf-8").hex() - case bytes(): - value = "0x" + template_value.hex() - case _: - raise DeploymentFailedError( - f"Unexpected template value type {template_variable_name}: {template_value.__class__}" - ) - - program_lines, matches = _replace_template_variable(program_lines, template_variable_name, value) - - return "\n".join(program_lines) - - -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): - """Action to take if an Application has been updated""" - - Fail = 0 - """Fail the deployment""" - UpdateApp = 1 - """Update the Application with the new approval and clear programs""" - ReplaceApp = 2 - """Create a new Application and delete the old Application in a single transaction""" - AppendApp = 3 - """Create a new application""" - - -class OnSchemaBreak(Enum): - """Action to take if an Application's schema has breaking changes""" - - Fail = 0 - """Fail the deployment""" - ReplaceApp = 2 - """Create a new Application and delete the old Application in a single transaction""" - AppendApp = 3 - """Create a new Application""" - - -class OperationPerformed(Enum): - """Describes the actions taken during deployment""" - - Nothing = 0 - """An existing Application was found""" - Create = 1 - """No existing Application was found, created a new Application""" - Update = 2 - """An existing Application was found, but was out of date, updated to latest version""" - Replace = 3 - """An existing Application was found, but was out of date, created a new Application and deleted the original""" - - -@dataclasses.dataclass(kw_only=True) -class DeployResponse: - """Describes the action taken during deployment, related transactions and the {py:class}`AppMetaData`""" - - app: AppMetaData - create_response: TransactionResponse | None = None - delete_response: TransactionResponse | None = None - update_response: TransactionResponse | None = None - action_taken: OperationPerformed = OperationPerformed.Nothing - - -@dataclasses.dataclass(kw_only=True) -class DeployCallArgs: - """Parameters used to update or delete an application when calling - {py:meth}`~algokit_utils.ApplicationClient.deploy`""" - - 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 ABICall: - method: ABIMethod | bool | None = None - args: ABIArgsDict = dataclasses.field(default_factory=dict) - - -@dataclasses.dataclass(kw_only=True) -class DeployCreateCallArgs(DeployCallArgs): - """Parameters used to create an application when calling {py:meth}`~algokit_utils.ApplicationClient.deploy`""" - - extra_pages: int | None = None - on_complete: transaction.OnComplete | None = None - - -@dataclasses.dataclass(kw_only=True) -class ABICallArgs(DeployCallArgs, ABICall): - """ABI Parameters used to update or delete an application when calling - {py:meth}`~algokit_utils.ApplicationClient.deploy`""" - - -@dataclasses.dataclass(kw_only=True) -class ABICreateCallArgs(DeployCreateCallArgs, ABICall): - """ABI Parameters used to create an application when calling {py:meth}`~algokit_utils.ApplicationClient.deploy`""" - - -class DeployCallArgsDict(TypedDict, total=False): - """Parameters used to update or delete an application when calling - {py:meth}`~algokit_utils.ApplicationClient.deploy`""" - - 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 ABICallArgsDict(DeployCallArgsDict, TypedDict, total=False): - """ABI Parameters used to update or delete an application when calling - {py:meth}`~algokit_utils.ApplicationClient.deploy`""" - - method: ABIMethod | bool - args: ABIArgsDict - - -class DeployCreateCallArgsDict(DeployCallArgsDict, TypedDict, total=False): - """Parameters used to create an application when calling {py:meth}`~algokit_utils.ApplicationClient.deploy`""" - - extra_pages: int | None - on_complete: transaction.OnComplete - - -class ABICreateCallArgsDict(DeployCreateCallArgsDict, TypedDict, total=False): - """ABI Parameters used to create an application when calling {py:meth}`~algokit_utils.ApplicationClient.deploy`""" - - method: ABIMethod | bool - args: ABIArgsDict - - -@dataclasses.dataclass(kw_only=True) -class Deployer: - app_client: "ApplicationClient" - creator: str - signer: TransactionSigner - sender: str - existing_app_metadata_or_reference: AppReference | AppMetaData - new_app_metadata: AppDeployMetaData - on_update: OnUpdate - on_schema_break: OnSchemaBreak - create_args: ABICreateCallArgs | ABICreateCallArgsDict | DeployCreateCallArgs | None - update_args: ABICallArgs | ABICallArgsDict | DeployCallArgs | None - delete_args: ABICallArgs | ABICallArgsDict | DeployCallArgs | None - - def deploy(self) -> DeployResponse: - """Ensures app associated with app client's creator is present and up to date""" - assert self.app_client.approval - assert self.app_client.clear - - if self.existing_app_metadata_or_reference.app_id == 0: - logger.info(f"{self.new_app_metadata.name} not found in {self.creator} account, deploying app.") - return self._create_app() - - assert isinstance(self.existing_app_metadata_or_reference, AppMetaData) - logger.debug( - f"{self.existing_app_metadata_or_reference.name} found in {self.creator} account, " - f"with app id {self.existing_app_metadata_or_reference.app_id}, " - f"version={self.existing_app_metadata_or_reference.version}." - ) - - app_changes = check_for_app_changes( - self.app_client.algod_client, - new_approval=self.app_client.approval.raw_binary, - new_clear=self.app_client.clear.raw_binary, - new_global_schema=self.app_client.app_spec.global_state_schema, - new_local_schema=self.app_client.app_spec.local_state_schema, - app_id=self.existing_app_metadata_or_reference.app_id, - ) - - if app_changes.schema_breaking_change: - logger.warning(f"Detected a breaking app schema change: {app_changes.schema_change_description}") - return self._deploy_breaking_change() - - if app_changes.app_updated: - logger.info(f"Detected a TEAL update in app id {self.existing_app_metadata_or_reference.app_id}") - return self._deploy_update() - - logger.info("No detected changes in app, nothing to do.") - return DeployResponse(app=self.existing_app_metadata_or_reference) - - def _deploy_breaking_change(self) -> DeployResponse: - assert isinstance(self.existing_app_metadata_or_reference, AppMetaData) - if self.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 self.on_schema_break == OnSchemaBreak.AppendApp: - logger.info("Schema break detected and on_schema_break=AppendApp, will attempt to create new app") - return self._create_app() - - if self.existing_app_metadata_or_reference.deletable: - logger.info( - "App is deletable and on_schema_break=ReplaceApp, will attempt to create new app and delete old app" - ) - elif self.existing_app_metadata_or_reference.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 self._create_and_delete_app() - - def _deploy_update(self) -> DeployResponse: - assert isinstance(self.existing_app_metadata_or_reference, AppMetaData) - if self.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 self.on_update == OnUpdate.AppendApp: - logger.info("Update detected and on_update=AppendApp, will attempt to create new app") - return self._create_app() - elif self.existing_app_metadata_or_reference.updatable and self.on_update == OnUpdate.UpdateApp: - logger.info("App is updatable and on_update=UpdateApp, will update app") - return self._update_app() - elif self.existing_app_metadata_or_reference.updatable and self.on_update == OnUpdate.ReplaceApp: - logger.warning( - "App is updatable but on_update=ReplaceApp, will attempt to create new app and delete old app" - ) - return self._create_and_delete_app() - elif self.on_update == OnUpdate.ReplaceApp: - if self.existing_app_metadata_or_reference.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 self._create_and_delete_app() - else: - if self.existing_app_metadata_or_reference.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 self._update_app() - - def _create_app(self) -> DeployResponse: - assert self.app_client.existing_deployments - - method, abi_args, parameters = _convert_deploy_args( - self.create_args, self.new_app_metadata, self.signer, self.sender - ) - create_response = self.app_client.create( - method, - parameters, - **abi_args, - ) - logger.info( - f"{self.new_app_metadata.name} ({self.new_app_metadata.version}) deployed successfully, " - f"with app id {self.app_client.app_id}." - ) - assert create_response.confirmed_round is not None - app_metadata = _create_metadata(self.new_app_metadata, self.app_client.app_id, create_response.confirmed_round) - self.app_client.existing_deployments.apps[self.new_app_metadata.name] = app_metadata - return DeployResponse(app=app_metadata, create_response=create_response, action_taken=OperationPerformed.Create) - - def _create_and_delete_app(self) -> DeployResponse: - assert self.app_client.existing_deployments - assert isinstance(self.existing_app_metadata_or_reference, AppMetaData) - - logger.info( - f"Replacing {self.existing_app_metadata_or_reference.name} " - f"({self.existing_app_metadata_or_reference.version}) with " - f"{self.new_app_metadata.name} ({self.new_app_metadata.version}) in {self.creator} account." - ) - atc = AtomicTransactionComposer() - create_method, create_abi_args, create_parameters = _convert_deploy_args( - self.create_args, self.new_app_metadata, self.signer, self.sender - ) - self.app_client.compose_create( - atc, - create_method, - create_parameters, - **create_abi_args, - ) - create_txn_index = len(atc.txn_list) - 1 - delete_method, delete_abi_args, delete_parameters = _convert_deploy_args( - self.delete_args, self.new_app_metadata, self.signer, self.sender - ) - self.app_client.compose_delete( - atc, - delete_method, - delete_parameters, - **delete_abi_args, - ) - delete_txn_index = len(atc.txn_list) - 1 - create_delete_response = self.app_client.execute_atc(atc) - create_response = TransactionResponse.from_atr(create_delete_response, create_txn_index) - delete_response = TransactionResponse.from_atr(create_delete_response, delete_txn_index) - self.app_client.app_id = get_app_id_from_tx_id(self.app_client.algod_client, create_response.tx_id) - logger.info( - f"{self.new_app_metadata.name} ({self.new_app_metadata.version}) deployed successfully, " - f"with app id {self.app_client.app_id}." - ) - logger.info( - f"{self.existing_app_metadata_or_reference.name} " - f"({self.existing_app_metadata_or_reference.version}) with app id " - f"{self.existing_app_metadata_or_reference.app_id}, deleted successfully." - ) - - app_metadata = _create_metadata( - self.new_app_metadata, self.app_client.app_id, create_delete_response.confirmed_round - ) - self.app_client.existing_deployments.apps[self.new_app_metadata.name] = app_metadata - - return DeployResponse( - app=app_metadata, - create_response=create_response, - delete_response=delete_response, - action_taken=OperationPerformed.Replace, - ) - - def _update_app(self) -> DeployResponse: - assert self.app_client.existing_deployments - assert isinstance(self.existing_app_metadata_or_reference, AppMetaData) - - logger.info( - f"Updating {self.existing_app_metadata_or_reference.name} to {self.new_app_metadata.version} in " - f"{self.creator} account, with app id {self.existing_app_metadata_or_reference.app_id}" - ) - method, abi_args, parameters = _convert_deploy_args( - self.update_args, self.new_app_metadata, self.signer, self.sender - ) - update_response = self.app_client.update( - method, - parameters, - **abi_args, - ) - app_metadata = _create_metadata( - self.new_app_metadata, - self.app_client.app_id, - self.existing_app_metadata_or_reference.created_round, - updated_round=update_response.confirmed_round, - original_metadata=self.existing_app_metadata_or_reference.created_metadata, - ) - self.app_client.existing_deployments.apps[self.new_app_metadata.name] = app_metadata - return DeployResponse(app=app_metadata, update_response=update_response, action_taken=OperationPerformed.Update) - - -def _create_metadata( - app_spec_note: AppDeployMetaData, - app_id: int, - created_round: int, - updated_round: int | None = None, - original_metadata: AppDeployMetaData | None = None, -) -> AppMetaData: - return 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, - name=app_spec_note.name, - version=app_spec_note.version, - deletable=app_spec_note.deletable, - updatable=app_spec_note.updatable, - deleted=False, - ) - - -def _convert_deploy_args( - _args: DeployCallArgs | DeployCallArgsDict | None, - note: AppDeployMetaData, - signer: TransactionSigner | None, - sender: str | None, -) -> tuple[ABIMethod | bool | None, ABIArgsDict, CreateCallParameters]: - args = _args.__dict__ if isinstance(_args, DeployCallArgs) else dict(_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_app_id_from_tx_id(algod_client: "AlgodClient", tx_id: str) -> int: - """Finds the app_id for provided transaction id""" - 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 +from algokit_utils._legacy_v2.deploy import * # noqa: F403 diff --git a/src/algokit_utils/dispenser_api.py b/src/algokit_utils/dispenser_api.py index 66593e80..1dc9e175 100644 --- a/src/algokit_utils/dispenser_api.py +++ b/src/algokit_utils/dispenser_api.py @@ -1,178 +1 @@ -import contextlib -import enum -import logging -import os -from dataclasses import dataclass - -import httpx - -logger = logging.getLogger(__name__) - - -class DispenserApiConfig: - BASE_URL = "https://api.dispenser.algorandfoundation.tools" - - -class DispenserAssetName(enum.IntEnum): - ALGO = 0 - - -@dataclass -class DispenserAsset: - asset_id: int - decimals: int - description: str - - -@dataclass -class DispenserFundResponse: - tx_id: str - amount: int - - -@dataclass -class DispenserLimitResponse: - amount: int - - -DISPENSER_ASSETS = { - DispenserAssetName.ALGO: DispenserAsset( - asset_id=0, - decimals=6, - description="Algo", - ), -} -DISPENSER_REQUEST_TIMEOUT = 15 -DISPENSER_ACCESS_TOKEN_KEY = "ALGOKIT_DISPENSER_ACCESS_TOKEN" - - -class TestNetDispenserApiClient: - """ - Client for interacting with the [AlgoKit TestNet Dispenser API](https://github.com/algorandfoundation/algokit/blob/main/docs/testnet_api.md). - To get started create a new access token via `algokit dispenser login --ci` - and pass it to the client constructor as `auth_token`. - Alternatively set the access token as environment variable `ALGOKIT_DISPENSER_ACCESS_TOKEN`, - and it will be auto loaded. If both are set, the constructor argument takes precedence. - - Default request timeout is 15 seconds. Modify by passing `request_timeout` to the constructor. - """ - - auth_token: str - request_timeout = DISPENSER_REQUEST_TIMEOUT - - def __init__(self, auth_token: str | None = None, request_timeout: int = DISPENSER_REQUEST_TIMEOUT): - auth_token_from_env = os.getenv(DISPENSER_ACCESS_TOKEN_KEY) - - if auth_token: - self.auth_token = auth_token - elif auth_token_from_env: - self.auth_token = auth_token_from_env - else: - raise Exception( - f"Can't init AlgoKit TestNet Dispenser API client " - f"because neither environment variable {DISPENSER_ACCESS_TOKEN_KEY} or " - "the auth_token were provided." - ) - - self.request_timeout = request_timeout - - def _process_dispenser_request( - self, *, auth_token: str, url_suffix: str, data: dict | None = None, method: str = "POST" - ) -> httpx.Response: - """ - Generalized method to process http requests to dispenser API - """ - - headers = {"Authorization": f"Bearer {(auth_token)}"} - - # Set request arguments - request_args = { - "url": f"{DispenserApiConfig.BASE_URL}/{url_suffix}", - "headers": headers, - "timeout": self.request_timeout, - } - - if method.upper() != "GET" and data is not None: - request_args["json"] = data - - try: - response: httpx.Response = getattr(httpx, method.lower())(**request_args) - response.raise_for_status() - return response - - except httpx.HTTPStatusError as err: - error_message = f"Error processing dispenser API request: {err.response.status_code}" - error_response = None - with contextlib.suppress(Exception): - error_response = err.response.json() - - if error_response and error_response.get("code"): - error_message = error_response.get("code") - - elif err.response.status_code == httpx.codes.BAD_REQUEST: - error_message = err.response.json()["message"] - - raise Exception(error_message) from err - - except Exception as err: - error_message = "Error processing dispenser API request" - logger.debug(f"{error_message}: {err}", exc_info=True) - raise err - - def fund(self, address: str, amount: int, asset_id: int) -> DispenserFundResponse: - """ - Fund an account with Algos from the dispenser API - """ - - try: - response = self._process_dispenser_request( - auth_token=self.auth_token, - url_suffix=f"fund/{asset_id}", - data={"receiver": address, "amount": amount, "assetID": asset_id}, - method="POST", - ) - - content = response.json() - return DispenserFundResponse(tx_id=content["txID"], amount=content["amount"]) - - except Exception as err: - logger.exception(f"Error funding account {address}: {err}") - raise err - - def refund(self, refund_txn_id: str) -> None: - """ - Register a refund for a transaction with the dispenser API - """ - - try: - self._process_dispenser_request( - auth_token=self.auth_token, - url_suffix="refund", - data={"refundTransactionID": refund_txn_id}, - method="POST", - ) - - except Exception as err: - logger.exception(f"Error issuing refund for txn_id {refund_txn_id}: {err}") - raise err - - def get_limit( - self, - address: str, - ) -> DispenserLimitResponse: - """ - Get current limit for an account with Algos from the dispenser API - """ - - try: - response = self._process_dispenser_request( - auth_token=self.auth_token, - url_suffix=f"fund/{DISPENSER_ASSETS[DispenserAssetName.ALGO].asset_id}/limit", - method="GET", - ) - content = response.json() - - return DispenserLimitResponse(amount=content["amount"]) - except Exception as err: - logger.exception(f"Error setting limit for account {address}: {err}") - raise err +from algokit_utils.clients.dispenser_api_client import * # noqa: F403 diff --git a/src/algokit_utils/errors/__init__.py b/src/algokit_utils/errors/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/algokit_utils/logic_error.py b/src/algokit_utils/logic_error.py index 56d22f9f..2b750b56 100644 --- a/src/algokit_utils/logic_error.py +++ b/src/algokit_utils/logic_error.py @@ -1,85 +1 @@ -import re -from copy import copy -from typing import TYPE_CHECKING, TypedDict - -from algokit_utils.models import SimulationTrace - -if TYPE_CHECKING: - from algosdk.source_map import SourceMap as AlgoSourceMap - -__all__ = [ - "LogicError", - "parse_logic_error", -] - -LOGIC_ERROR = ( - ".*transaction (?P[A-Z0-9]+): logic eval error: (?P.*). Details: .*pc=(?P[0-9]+).*" -) - - -class LogicErrorData(TypedDict): - transaction_id: str - message: str - pc: int - - -def parse_logic_error( - error_str: str, -) -> LogicErrorData | None: - match = re.match(LOGIC_ERROR, error_str) - if match is None: - return None - - return { - "transaction_id": match.group("transaction_id"), - "message": match.group("message"), - "pc": int(match.group("pc")), - } - - -class LogicError(Exception): - def __init__( # noqa: PLR0913 - self, - *, - logic_error_str: str, - program: str, - source_map: "AlgoSourceMap | None", - transaction_id: str, - message: str, - pc: int, - logic_error: Exception | None = None, - traces: list[SimulationTrace] | None = None, - ): - self.logic_error = logic_error - self.logic_error_str = logic_error_str - self.program = program - self.source_map = source_map - self.lines = program.split("\n") - self.transaction_id = transaction_id - self.message = message - self.pc = pc - self.traces = traces - - self.line_no = self.source_map.get_line_for_pc(self.pc) if self.source_map else None - - def __str__(self) -> str: - return ( - f"Txn {self.transaction_id} had error '{self.message}' at PC {self.pc}" - + (":" if self.line_no is None else f" and Source Line {self.line_no}:") - + f"\n{self.trace()}" - ) - - def trace(self, lines: int = 5) -> str: - if self.line_no is None: - return """ -Could not determine TEAL source line for the error as no approval source map was provided, to receive a trace of the -error please provide an approval SourceMap. Either by: - 1.) Providing template_values when creating the ApplicationClient, so a SourceMap can be obtained automatically OR - 2.) Set approval_source_map from a previously compiled approval program OR - 3.) Import a previously exported source map using import_source_map""" - - program_lines = copy(self.lines) - program_lines[self.line_no] += "\t\t<-- Error" - lines_before = max(0, self.line_no - lines) - lines_after = min(len(program_lines), self.line_no + lines) - return "\n\t" + "\n\t".join(program_lines[lines_before:lines_after]) +from algokit_utils._legacy_v2.logic_error import * # noqa: F403 diff --git a/src/algokit_utils/models/__init__.py b/src/algokit_utils/models/__init__.py new file mode 100644 index 00000000..bcffc093 --- /dev/null +++ b/src/algokit_utils/models/__init__.py @@ -0,0 +1 @@ +from algokit_utils._legacy_v2.models import * # noqa: F403 diff --git a/src/algokit_utils/models/common.py b/src/algokit_utils/models/common.py new file mode 100644 index 00000000..e69de29b diff --git a/src/algokit_utils/network_clients.py b/src/algokit_utils/network_clients.py index 2de270da..a9dc5de2 100644 --- a/src/algokit_utils/network_clients.py +++ b/src/algokit_utils/network_clients.py @@ -1,130 +1 @@ -import dataclasses -import os -from typing import Literal -from urllib import parse - -from algosdk.kmd import KMDClient -from algosdk.v2client.algod import AlgodClient -from algosdk.v2client.indexer import IndexerClient - -__all__ = [ - "AlgoClientConfig", - "get_algod_client", - "get_algonode_config", - "get_default_localnet_config", - "get_indexer_client", - "get_kmd_client_from_algod_client", - "is_localnet", - "is_mainnet", - "is_testnet", - "AlgoClientConfigs", - "get_kmd_client", -] - - -@dataclasses.dataclass -class AlgoClientConfig: - """Connection details for connecting to an {py:class}`algosdk.v2client.algod.AlgodClient` or - {py:class}`algosdk.v2client.indexer.IndexerClient`""" - - server: str - """URL for the service e.g. `http://localhost:4001` or `https://testnet-api.algonode.cloud`""" - token: str - """API Token to authenticate with the service""" - - -@dataclasses.dataclass -class AlgoClientConfigs: - algod_config: AlgoClientConfig - indexer_config: AlgoClientConfig - kmd_config: AlgoClientConfig | None - - -def get_default_localnet_config(config: Literal["algod", "indexer", "kmd"]) -> AlgoClientConfig: - """Returns the client configuration to point to the default LocalNet""" - port = {"algod": 4001, "indexer": 8980, "kmd": 4002}[config] - return AlgoClientConfig(server=f"http://localhost:{port}", token="a" * 64) - - -def get_algonode_config( - network: Literal["testnet", "mainnet"], config: Literal["algod", "indexer"], token: str -) -> AlgoClientConfig: - client = "api" if config == "algod" else "idx" - return AlgoClientConfig( - server=f"https://{network}-{client}.algonode.cloud", - token=token, - ) - - -def get_algod_client(config: AlgoClientConfig | None = None) -> AlgodClient: - """Returns an {py:class}`algosdk.v2client.algod.AlgodClient` from `config` or environment - - If no configuration provided will use environment variables `ALGOD_SERVER`, `ALGOD_PORT` and `ALGOD_TOKEN`""" - config = config or _get_config_from_environment("ALGOD") - headers = {"X-Algo-API-Token": config.token} - return AlgodClient(config.token, config.server, headers) - - -def get_kmd_client(config: AlgoClientConfig | None = None) -> KMDClient: - """Returns an {py:class}`algosdk.kmd.KMDClient` from `config` or environment - - If no configuration provided will use environment variables `KMD_SERVER`, `KMD_PORT` and `KMD_TOKEN`""" - config = config or _get_config_from_environment("KMD") - return KMDClient(config.token, config.server) # type: ignore[no-untyped-call] - - -def get_indexer_client(config: AlgoClientConfig | None = None) -> IndexerClient: - """Returns an {py:class}`algosdk.v2client.indexer.IndexerClient` from `config` or environment. - - If no configuration provided will use environment variables `INDEXER_SERVER`, `INDEXER_PORT` and `INDEXER_TOKEN`""" - config = config or _get_config_from_environment("INDEXER") - headers = {"X-Indexer-API-Token": config.token} - return IndexerClient(config.token, config.server, headers) # type: ignore[no-untyped-call] - - -def is_localnet(client: AlgodClient) -> bool: - """Returns True if client genesis is `devnet-v1` or `sandnet-v1`""" - params = client.suggested_params() - return params.gen in ["devnet-v1", "sandnet-v1", "dockernet-v1"] - - -def is_mainnet(client: AlgodClient) -> bool: - """Returns True if client genesis is `mainnet-v1`""" - params = client.suggested_params() - return params.gen in ["mainnet-v1.0", "mainnet-v1", "mainnet"] - - -def is_testnet(client: AlgodClient) -> bool: - """Returns True if client genesis is `testnet-v1`""" - params = client.suggested_params() - return params.gen in ["testnet-v1.0", "testnet-v1", "testnet"] - - -def get_kmd_client_from_algod_client(client: AlgodClient) -> KMDClient: - """Returns an {py:class}`algosdk.kmd.KMDClient` from supplied `client` - - Will use the same address as provided `client` but on port specified by `KMD_PORT` environment variable, - or 4002 by default""" - # We can only use Kmd on the LocalNet otherwise it's not exposed so this makes some assumptions - # (e.g. same token and server as algod and port 4002 by default) - port = os.getenv("KMD_PORT", "4002") - server = _replace_kmd_port(client.algod_address, port) - return KMDClient(client.algod_token, server) # type: ignore[no-untyped-call] - - -def _replace_kmd_port(address: str, port: str) -> str: - parsed_algod = parse.urlparse(address) - kmd_host = parsed_algod.netloc.split(":", maxsplit=1)[0] + f":{port}" - kmd_parsed = parsed_algod._replace(netloc=kmd_host) - return parse.urlunparse(kmd_parsed) - - -def _get_config_from_environment(environment_prefix: str) -> AlgoClientConfig: - server = os.getenv(f"{environment_prefix}_SERVER") - if server is None: - raise Exception(f"Server environment variable not set: {environment_prefix}_SERVER") - port = os.getenv(f"{environment_prefix}_PORT") - if port: - parsed = parse.urlparse(server) - server = parsed._replace(netloc=f"{parsed.hostname}:{port}").geturl() - return AlgoClientConfig(server, os.getenv(f"{environment_prefix}_TOKEN", "")) +from algokit_utils._legacy_v2.network_clients import * # noqa: F403 diff --git a/src/algokit_utils/transactions/__init__.py b/src/algokit_utils/transactions/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/algokit_utils/transactions/models.py b/src/algokit_utils/transactions/models.py new file mode 100644 index 00000000..e69de29b diff --git a/src/algokit_utils/beta/composer.py b/src/algokit_utils/transactions/transaction_composer.py similarity index 77% rename from src/algokit_utils/beta/composer.py rename to src/algokit_utils/transactions/transaction_composer.py index a8aaa4b8..254b2a30 100644 --- a/src/algokit_utils/beta/composer.py +++ b/src/algokit_utils/transactions/transaction_composer.py @@ -22,21 +22,6 @@ class SenderParam: @dataclass(frozen=True) class CommonTxnParams: - """ - Common transaction parameters. - - :param signer: The function used to sign transactions. - :param rekey_to: Change the signing key of the sender to the given address. - :param note: Note to attach to the transaction. - :param lease: Prevent multiple transactions with the same lease being included within the validity window. - :param static_fee: The transaction fee. In most cases you want to use `extra_fee` unless setting the fee to 0 to be covered by another transaction. - :param extra_fee: The fee to pay IN ADDITION to the suggested fee. Useful for covering inner transaction fees. - :param max_fee: Throw an error if the fee for the transaction is more than this amount. - :param validity_window: How many rounds the transaction should be valid for. - :param first_valid_round: Set the first round this transaction is valid. If left undefined, the value from algod will be used. Only set this when you intentionally want this to be some time in the future. - :param last_valid_round: The last round this transaction is valid. It is recommended to use validity_window instead. - """ - signer: TransactionSigner | None = None rekey_to: str | None = None note: bytes | None = None @@ -75,22 +60,6 @@ class _RequiredAssetCreateParams(SenderParam): @dataclass(frozen=True) class AssetCreateParams(CommonTxnParams, _RequiredAssetCreateParams): - """ - Asset creation parameters. - - :param total: The total amount of the smallest divisible unit to create. - :param decimals: The amount of decimal places the asset should have. - :param default_frozen: Whether the asset is frozen by default in the creator address. - :param manager: The address that can change the manager, reserve, clawback, and freeze addresses. There will permanently be no manager if undefined or an empty string. - :param reserve: The address that holds the uncirculated supply. - :param freeze: The address that can freeze the asset in any account. Freezing will be permanently disabled if undefined or an empty string. - :param clawback: The address that can clawback the asset from any account. Clawback will be permanently disabled if undefined or an empty string. - :param unit_name: The short ticker name for the asset. - :param asset_name: The full name of the asset. - :param url: The metadata URL for the asset. - :param metadata_hash: Hash of the metadata contained in the metadata URL. - """ - decimals: int | None = None default_frozen: bool | None = None manager: str | None = None @@ -110,16 +79,6 @@ class _RequiredAssetConfigParams(SenderParam): @dataclass(frozen=True) class AssetConfigParams(CommonTxnParams, _RequiredAssetConfigParams): - """ - Asset configuration parameters. - - :param asset_id: ID of the asset. - :param manager: The address that can change the manager, reserve, clawback, and freeze addresses. There will permanently be no manager if undefined or an empty string. - :param reserve: The address that holds the uncirculated supply. - :param freeze: The address that can freeze the asset in any account. Freezing will be permanently disabled if undefined or an empty string. - :param clawback: The address that can clawback the asset from any account. Clawback will be permanently disabled if undefined or an empty string. - """ - manager: str | None = None reserve: str | None = None freeze: str | None = None @@ -169,17 +128,6 @@ class _RequiredOnlineKeyRegParams(SenderParam): @dataclass(frozen=True) class OnlineKeyRegParams(CommonTxnParams, _RequiredOnlineKeyRegParams): - """ - Online key registration parameters. - - :param vote_key: The root participation public key. - :param selection_key: The VRF public key. - :param vote_first: The first round that the participation key is valid. Not to be confused with the `first_valid` round of the keyreg transaction. - :param vote_last: The last round that the participation key is valid. Not to be confused with the `last_valid` round of the keyreg transaction. - :param vote_key_dilution: This is the dilution for the 2-level participation key. It determines the interval (number of rounds) for generating new ephemeral keys. - :param state_proof_key: The 64 byte state proof public key commitment. - """ - state_proof_key: bytes | None = None @@ -192,16 +140,6 @@ class _RequiredAssetTransferParams(SenderParam): @dataclass(frozen=True) class AssetTransferParams(CommonTxnParams, _RequiredAssetTransferParams): - """ - Asset transfer parameters. - - :param asset_id: ID of the asset. - :param amount: Amount of the asset to transfer (smallest divisible unit). - :param receiver: The account to send the asset to. - :param clawback_target: The account to take the asset from. - :param close_asset_to: The account to close the asset to. - """ - clawback_target: str | None = None close_asset_to: str | None = None @@ -284,20 +222,7 @@ class MethodCallParams(CommonTxnParams, _RequiredMethodCallParams): ] -class AlgokitComposer: - """ - A class for composing and managing Algorand transactions using the Algosdk library. - - Attributes: - txn_method_map (dict[str, algosdk.abi.Method]): A dictionary that maps transaction IDs to their corresponding ABI methods. - txns (List[Union[TransactionWithSigner, TxnParams, AtomicTransactionComposer]]): A list of transactions that have not yet been composed. - atc (AtomicTransactionComposer): An instance of AtomicTransactionComposer used to compose transactions. - algod (AlgodClient): The AlgodClient instance used by the composer for suggested params. - get_suggested_params (Callable[[], algosdk.future.transaction.SuggestedParams]): A function that returns suggested parameters for transactions. - get_signer (Callable[[str], TransactionSigner]): A function that takes an address as input and returns a TransactionSigner for that address. - default_validity_window (int): The default validity window for transactions. - """ - +class TransactionComposer: def __init__( self, algod: AlgodClient, @@ -305,15 +230,6 @@ def __init__( get_suggested_params: Callable[[], algosdk.transaction.SuggestedParams] | None = None, default_validity_window: int | None = None, ): - """ - Initialize an instance of the AlgokitComposer class. - - Args: - algod (AlgodClient): An instance of AlgodClient used to get suggested params and send transactions. - get_signer (Callable[[str], TransactionSigner]): A function that takes an address as input and returns a TransactionSigner for that address. - get_suggested_params (Optional[Callable[[], algosdk.future.transaction.SuggestedParams]], optional): A function that returns suggested parameters for transactions. If not provided, it defaults to using algod.suggested_params(). Defaults to None. - default_validity_window (Optional[int], optional): The default validity window for transactions. If not provided, it defaults to 10. Defaults to None. - """ self.txn_method_map: dict[str, algosdk.abi.Method] = {} self.txns: list[TransactionWithSigner | TxnParams | AtomicTransactionComposer] = [] self.atc: AtomicTransactionComposer = AtomicTransactionComposer() @@ -323,47 +239,47 @@ def __init__( self.get_signer: Callable[[str], TransactionSigner] = get_signer self.default_validity_window: int = default_validity_window or 10 - def add_payment(self, params: PayParams) -> "AlgokitComposer": + def add_payment(self, params: PayParams) -> "TransactionComposer": self.txns.append(params) return self - def add_asset_create(self, params: AssetCreateParams) -> "AlgokitComposer": + def add_asset_create(self, params: AssetCreateParams) -> "TransactionComposer": self.txns.append(params) return self - def add_asset_config(self, params: AssetConfigParams) -> "AlgokitComposer": + def add_asset_config(self, params: AssetConfigParams) -> "TransactionComposer": self.txns.append(params) return self - def add_asset_freeze(self, params: AssetFreezeParams) -> "AlgokitComposer": + def add_asset_freeze(self, params: AssetFreezeParams) -> "TransactionComposer": self.txns.append(params) return self - def add_asset_destroy(self, params: AssetDestroyParams) -> "AlgokitComposer": + def add_asset_destroy(self, params: AssetDestroyParams) -> "TransactionComposer": self.txns.append(params) return self - def add_asset_transfer(self, params: AssetTransferParams) -> "AlgokitComposer": + def add_asset_transfer(self, params: AssetTransferParams) -> "TransactionComposer": self.txns.append(params) return self - def add_asset_opt_in(self, params: AssetOptInParams) -> "AlgokitComposer": + def add_asset_opt_in(self, params: AssetOptInParams) -> "TransactionComposer": self.txns.append(params) return self - def add_app_call(self, params: AppCallParams) -> "AlgokitComposer": + def add_app_call(self, params: AppCallParams) -> "TransactionComposer": self.txns.append(params) return self - def add_online_key_reg(self, params: OnlineKeyRegParams) -> "AlgokitComposer": + def add_online_key_reg(self, params: OnlineKeyRegParams) -> "TransactionComposer": self.txns.append(params) return self - def add_atc(self, atc: AtomicTransactionComposer) -> "AlgokitComposer": + def add_atc(self, atc: AtomicTransactionComposer) -> "TransactionComposer": self.txns.append(atc) return self - def add_method_call(self, params: MethodCallParams) -> "AlgokitComposer": + def add_method_call(self, params: MethodCallParams) -> "TransactionComposer": self.txns.append(params) return self @@ -633,7 +549,7 @@ def _build_method_call( # noqa: C901, PLR0912 return self._build_atc(method_atc) - def _build_txn( # noqa: C901, PLR0912 + def _build_txn( # noqa: C901, PLR0912, PLR0911 self, txn: TransactionWithSigner | TxnParams | AtomicTransactionComposer, suggested_params: algosdk.transaction.SuggestedParams, diff --git a/tests/conftest.py b/tests/conftest.py index be23305b..e3997a2c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -24,7 +24,7 @@ ) from dotenv import load_dotenv -from tests import app_client_test +from legacy_v2_tests import app_client_test if TYPE_CHECKING: from algosdk.kmd import KMDClient diff --git a/tests/test_algorand_client.py b/tests/test_algorand_client.py index 5f258640..8b7c448d 100644 --- a/tests/test_algorand_client.py +++ b/tests/test_algorand_client.py @@ -3,8 +3,8 @@ import pytest from algokit_utils import Account, ApplicationClient -from algokit_utils.beta.account_manager import AddressAndSigner -from algokit_utils.beta.algorand_client import ( +from algokit_utils.accounts.account_manager import AddressAndSigner +from algokit_utils.clients.algorand_client import ( AlgorandClient, AssetCreateParams, AssetOptInParams, From 1c77ad87c137e38707c6d71510594417c82057f6 Mon Sep 17 00:00:00 2001 From: Al Date: Mon, 4 Nov 2024 17:16:53 +0100 Subject: [PATCH 2/6] feat: TransactionComposer & AppManager implementation; various ongoing refactoring efforts (#120) * feat: Initial AppManager implementation; wip on Composer; mypy tweaks; initial tests - Add AppManager class with methods for compiling TEAL, managing app state, and handling template variables - Update TransactionComposer to use AppManager - Move Account model to a separate file and update imports - Add new models for ABI values and application constants - Improve type annotations and remove unnecessary type ignores - Add initial tests for AppManager template substitution and comment stripping - Update mypy configuration to *globally* exclude untyped calls in algosdk -> removing ~50 individual mypy type ignore for algosdk * chore: removing models.py folders in favour of granular modules in root models namespace * chore: wip * feat: initial implementation of TransactionComposer --- legacy_v2_tests/conftest.py | 6 +- pyproject.toml | 9 + src/algokit_utils/__init__.py | 5 +- src/algokit_utils/_debugging.py | 4 +- .../_legacy_v2/_ensure_funded.py | 6 +- src/algokit_utils/_legacy_v2/_transfer.py | 8 +- src/algokit_utils/_legacy_v2/account.py | 14 +- .../_legacy_v2/application_client.py | 8 +- .../_legacy_v2/application_specification.py | 4 +- src/algokit_utils/_legacy_v2/asset.py | 2 +- src/algokit_utils/_legacy_v2/deploy.py | 56 +- src/algokit_utils/_legacy_v2/models.py | 38 +- .../_legacy_v2/network_clients.py | 6 +- src/algokit_utils/accounts/account_manager.py | 2 +- src/algokit_utils/applications/app_manager.py | 355 +++++++ src/algokit_utils/assets/asset_manager.py | 2 + src/algokit_utils/clients/algorand_client.py | 84 +- src/algokit_utils/clients/models.py | 0 src/algokit_utils/models/__init__.py | 3 + src/algokit_utils/models/abi.py | 4 + src/algokit_utils/models/account.py | 35 + src/algokit_utils/models/amount.py | 123 +++ src/algokit_utils/models/application.py | 5 + src/algokit_utils/models/common.py | 0 src/algokit_utils/transactions/models.py | 30 + .../transactions/transaction_composer.py | 905 ++++++++++++++---- .../transactions/transaction_creator.py | 2 + .../transactions/transaction_sender.py | 88 ++ .../applications/__init__.py | 0 .../test_comment_stripping.approved.txt | 30 + .../test_template_substitution.approved.txt | 21 + tests/applications/test_app_manager.py | 77 ++ .../models.py => tests/clients/__init__.py | 0 tests/clients/test_algorand_client.py | 223 +++++ tests/conftest.py | 8 +- tests/test_algorand_client.py | 222 ----- tests/test_transaction_composer.py | 212 ++++ .../transactions/__init__.py | 0 .../artifacts/hello_world/approval.teal | 62 ++ .../artifacts/hello_world/clear.teal | 5 + .../transactions/test_transaction_composer.py | 256 +++++ 41 files changed, 2328 insertions(+), 592 deletions(-) create mode 100644 src/algokit_utils/applications/app_manager.py create mode 100644 src/algokit_utils/assets/asset_manager.py delete mode 100644 src/algokit_utils/clients/models.py create mode 100644 src/algokit_utils/models/abi.py create mode 100644 src/algokit_utils/models/account.py create mode 100644 src/algokit_utils/models/amount.py create mode 100644 src/algokit_utils/models/application.py delete mode 100644 src/algokit_utils/models/common.py create mode 100644 src/algokit_utils/transactions/transaction_creator.py create mode 100644 src/algokit_utils/transactions/transaction_sender.py rename src/algokit_utils/accounts/models.py => tests/applications/__init__.py (100%) create mode 100644 tests/applications/snapshots/test_app_manager.approvals/test_comment_stripping.approved.txt create mode 100644 tests/applications/snapshots/test_app_manager.approvals/test_template_substitution.approved.txt create mode 100644 tests/applications/test_app_manager.py rename src/algokit_utils/applications/models.py => tests/clients/__init__.py (100%) create mode 100644 tests/clients/test_algorand_client.py delete mode 100644 tests/test_algorand_client.py create mode 100644 tests/test_transaction_composer.py rename src/algokit_utils/assets/models.py => tests/transactions/__init__.py (100%) create mode 100644 tests/transactions/artifacts/hello_world/approval.teal create mode 100644 tests/transactions/artifacts/hello_world/clear.teal create mode 100644 tests/transactions/test_transaction_composer.py diff --git a/legacy_v2_tests/conftest.py b/legacy_v2_tests/conftest.py index e3997a2c..dbe4be46 100644 --- a/legacy_v2_tests/conftest.py +++ b/legacy_v2_tests/conftest.py @@ -188,11 +188,11 @@ def generate_test_asset(algod_client: "AlgodClient", sender: Account, total: int note=None, lease=None, rekey_to=None, - ) # type: ignore[no-untyped-call] + ) - signed_transaction = txn.sign(sender.private_key) # type: ignore[no-untyped-call] + signed_transaction = txn.sign(sender.private_key) algod_client.send_transaction(signed_transaction) - ptx = algod_client.pending_transaction_info(txn.get_txid()) # type: ignore[no-untyped-call] + ptx = algod_client.pending_transaction_info(txn.get_txid()) if isinstance(ptx, dict) and "asset-index" in ptx and isinstance(ptx["asset-index"], int): return ptx["asset-index"] diff --git a/pyproject.toml b/pyproject.toml index 4e3a99a9..bd391ae5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -125,6 +125,7 @@ suppress-none-returning = true [tool.ruff.lint.per-file-ignores] "src/algokit_utils/beta/*" = ["ERA001", "E501", "PLR0911"] "path/to/file.py" = ["E402"] +"tests/clients/test_algorand_client.py" = ["ERA001"] [tool.poe.tasks] docs = ["docs-html-only", "docs-md-only"] @@ -149,6 +150,14 @@ disallow_any_generics = false implicit_reexport = false show_error_codes = true +untyped_calls_exclude = [ + "algosdk", +] + +[[tool.mypy.overrides]] +module = ["algosdk", "algosdk.*"] +disallow_untyped_calls = false + [tool.semantic_release] version_toml = "pyproject.toml:tool.poetry.version" remove_dist = false diff --git a/src/algokit_utils/__init__.py b/src/algokit_utils/__init__.py index 77959758..d89bad9b 100644 --- a/src/algokit_utils/__init__.py +++ b/src/algokit_utils/__init__.py @@ -30,9 +30,7 @@ from algokit_utils._legacy_v2.asset import opt_in, opt_out from algokit_utils._legacy_v2.common import Program from algokit_utils._legacy_v2.deploy import ( - DELETABLE_TEMPLATE_NAME, NOTE_PREFIX, - UPDATABLE_TEMPLATE_NAME, ABICallArgs, ABICallArgsDict, ABICreateCallArgs, @@ -61,7 +59,6 @@ ABIArgsDict, ABIMethod, ABITransactionResponse, - Account, CommonCallParameters, CommonCallParametersDict, CreateCallParameters, @@ -91,6 +88,8 @@ DispenserLimitResponse, TestNetDispenserApiClient, ) +from algokit_utils.models.account import Account +from algokit_utils.models.application import DELETABLE_TEMPLATE_NAME, UPDATABLE_TEMPLATE_NAME __all__ = [ # ==== LEGACY V2 EXPORTS BEGIN ==== diff --git a/src/algokit_utils/_debugging.py b/src/algokit_utils/_debugging.py index e8c0ef52..de5ed182 100644 --- a/src/algokit_utils/_debugging.py +++ b/src/algokit_utils/_debugging.py @@ -157,9 +157,7 @@ def _build_avm_sourcemap( # noqa: PLR0913 raise ValueError("Either raw teal or compiled teal must be provided") result = compiled_teal if compiled_teal else Program(str(raw_teal), client=client) - program_hash = base64.b64encode( - checksum(result.raw_binary) # type: ignore[no-untyped-call] - ).decode() + program_hash = base64.b64encode(checksum(result.raw_binary)).decode() source_map = result.source_map.__dict__ source_map["sources"] = [f"{file_name}{TEAL_FILE_EXT}"] if with_sources else [] diff --git a/src/algokit_utils/_legacy_v2/_ensure_funded.py b/src/algokit_utils/_legacy_v2/_ensure_funded.py index 23c87860..2db90f36 100644 --- a/src/algokit_utils/_legacy_v2/_ensure_funded.py +++ b/src/algokit_utils/_legacy_v2/_ensure_funded.py @@ -7,12 +7,12 @@ from algokit_utils._legacy_v2._transfer import TransferParameters, transfer from algokit_utils._legacy_v2.account import get_dispenser_account -from algokit_utils._legacy_v2.models import Account from algokit_utils._legacy_v2.network_clients import is_testnet from algokit_utils.clients.dispenser_api_client import ( DispenserAssetName, TestNetDispenserApiClient, ) +from algokit_utils.models.account import Account @dataclass(kw_only=True) @@ -63,7 +63,7 @@ def _get_address_to_fund(parameters: EnsureBalanceParameters) -> str: if isinstance(parameters.account_to_fund, str): return parameters.account_to_fund else: - return str(address_from_private_key(parameters.account_to_fund.private_key)) # type: ignore[no-untyped-call] + return str(address_from_private_key(parameters.account_to_fund.private_key)) def _get_account_info(client: AlgodClient, address_to_fund: str) -> dict: @@ -111,7 +111,7 @@ def _fund_using_transfer( fee_micro_algos=parameters.fee_micro_algos, ), ) - transaction_id = response.get_txid() # type: ignore[no-untyped-call] + transaction_id = response.get_txid() return EnsureFundedResponse(transaction_id=transaction_id, amount=response.amt) diff --git a/src/algokit_utils/_legacy_v2/_transfer.py b/src/algokit_utils/_legacy_v2/_transfer.py index baca5b2b..6b59cd4c 100644 --- a/src/algokit_utils/_legacy_v2/_transfer.py +++ b/src/algokit_utils/_legacy_v2/_transfer.py @@ -7,7 +7,7 @@ from algosdk.atomic_transaction_composer import AccountTransactionSigner from algosdk.transaction import AssetTransferTxn, PaymentTxn, SuggestedParams -from algokit_utils._legacy_v2.models import Account +from algokit_utils.models.account import Account if TYPE_CHECKING: from algosdk.v2client.algod import AlgodClient @@ -93,7 +93,7 @@ def transfer(client: "AlgodClient", parameters: TransferParameters) -> PaymentTx amt=params.micro_algos, note=params.note.encode("utf-8") if isinstance(params.note, str) else params.note, sp=params.suggested_params, - ) # type: ignore[no-untyped-call] + ) result = _send_transaction(client=client, transaction=transaction, parameters=params) assert isinstance(result, PaymentTxn) @@ -117,7 +117,7 @@ def transfer_asset(client: "AlgodClient", parameters: TransferAssetParameters) - note=params.note, index=params.asset_id, rekey_to=None, - ) # type: ignore[no-untyped-call] + ) result = _send_transaction(client=client, transaction=xfer_txn, parameters=params) assert isinstance(result, AssetTransferTxn) @@ -148,5 +148,5 @@ def _get_address(account: Account | AccountTransactionSigner) -> str: if type(account) is Account: return account.address else: - address = address_from_private_key(account.private_key) # type: ignore[no-untyped-call] + address = address_from_private_key(account.private_key) return str(address) diff --git a/src/algokit_utils/_legacy_v2/account.py b/src/algokit_utils/_legacy_v2/account.py index 819a448f..d98a875a 100644 --- a/src/algokit_utils/_legacy_v2/account.py +++ b/src/algokit_utils/_legacy_v2/account.py @@ -7,8 +7,8 @@ from algosdk.util import algos_to_microalgos from algokit_utils._legacy_v2._transfer import TransferParameters, transfer -from algokit_utils._legacy_v2.models import Account from algokit_utils._legacy_v2.network_clients import get_kmd_client_from_algod_client, is_localnet +from algokit_utils.models.account import Account if TYPE_CHECKING: from collections.abc import Callable @@ -32,8 +32,8 @@ def get_account_from_mnemonic(mnemonic: str) -> Account: """Convert a mnemonic (25 word passphrase) into an Account""" - private_key = to_private_key(mnemonic) # type: ignore[no-untyped-call] - address = address_from_private_key(private_key) # type: ignore[no-untyped-call] + private_key = to_private_key(mnemonic) + address = address_from_private_key(private_key) return Account(private_key=private_key, address=address) @@ -47,7 +47,7 @@ def create_kmd_wallet_account(kmd_client: "KMDClient", name: str) -> Account: account_key = key_ids[0] private_account_key = kmd_client.export_key(wallet_handle, "", account_key) - return get_account_from_mnemonic(from_private_key(private_account_key)) # type: ignore[no-untyped-call] + return get_account_from_mnemonic(from_private_key(private_account_key)) def get_or_create_kmd_wallet_account( @@ -79,7 +79,7 @@ def get_or_create_kmd_wallet_account( TransferParameters( from_account=get_dispenser_account(client), to_address=account.address, - micro_algos=algos_to_microalgos(fund_with_algos), # type: ignore[no-untyped-call] + micro_algos=algos_to_microalgos(fund_with_algos), ), ) @@ -139,7 +139,7 @@ def get_kmd_wallet_account( return None private_account_key = kmd_client.export_key(wallet_handle, "", matched_account_key) - return get_account_from_mnemonic(from_private_key(private_account_key)) # type: ignore[no-untyped-call] + return get_account_from_mnemonic(from_private_key(private_account_key)) def get_account( @@ -177,7 +177,7 @@ def get_account( if is_localnet(client): account = get_or_create_kmd_wallet_account(client, name, fund_with_algos, kmd_client) - os.environ[mnemonic_key] = from_private_key(account.private_key) # type: ignore[no-untyped-call] + os.environ[mnemonic_key] = from_private_key(account.private_key) return account raise Exception(f"Missing environment variable '{mnemonic_key}' when looking for account '{name}'") diff --git a/src/algokit_utils/_legacy_v2/application_client.py b/src/algokit_utils/_legacy_v2/application_client.py index 32851fa4..a52639d1 100644 --- a/src/algokit_utils/_legacy_v2/application_client.py +++ b/src/algokit_utils/_legacy_v2/application_client.py @@ -43,7 +43,6 @@ ABIArgType, ABIMethod, ABITransactionResponse, - Account, CreateCallParameters, CreateCallParametersDict, OnCompleteCallParameters, @@ -54,6 +53,7 @@ TransactionResponse, ) from algokit_utils.config import config +from algokit_utils.models.account import Account if typing.TYPE_CHECKING: from algosdk.v2client.algod import AlgodClient @@ -1021,7 +1021,7 @@ def add_method_call( # noqa: PLR0913 raise Exception(f"ABI arguments specified on a bare call: {', '.join(abi_args)}") atc.add_transaction( TransactionWithSigner( - txn=transaction.ApplicationCallTxn( # type: ignore[no-untyped-call] + txn=transaction.ApplicationCallTxn( sender=sender, sp=sp, index=app_id, @@ -1329,11 +1329,11 @@ def get_sender_from_signer(signer: TransactionSigner | None) -> str | None: """Returns the associated address of a signer, return None if no address found""" if isinstance(signer, AccountTransactionSigner): - sender = address_from_private_key(signer.private_key) # type: ignore[no-untyped-call] + sender = address_from_private_key(signer.private_key) assert isinstance(sender, str) return sender elif isinstance(signer, MultisigTransactionSigner): - sender = signer.msig.address() # type: ignore[no-untyped-call] + sender = signer.msig.address() assert isinstance(sender, str) return sender elif isinstance(signer, LogicSigTransactionSigner): diff --git a/src/algokit_utils/_legacy_v2/application_specification.py b/src/algokit_utils/_legacy_v2/application_specification.py index 392fce8d..865dece5 100644 --- a/src/algokit_utils/_legacy_v2/application_specification.py +++ b/src/algokit_utils/_legacy_v2/application_specification.py @@ -130,7 +130,7 @@ def _encode_state_schema(schema: StateSchema) -> dict[str, int]: def _decode_state_schema(data: dict[str, int]) -> StateSchema: - return StateSchema( # type: ignore[no-untyped-call] + return StateSchema( num_byte_slices=data.get("num_byte_slices", 0), num_uints=data.get("num_uints", 0), ) @@ -203,4 +203,4 @@ def export(self, directory: Path | str | None = None) -> None: def _state_schema(schema: dict[str, int]) -> StateSchema: - return StateSchema(schema.get("num-uint", 0), schema.get("num-byte-slice", 0)) # type: ignore[no-untyped-call] + return StateSchema(schema.get("num-uint", 0), schema.get("num-byte-slice", 0)) diff --git a/src/algokit_utils/_legacy_v2/asset.py b/src/algokit_utils/_legacy_v2/asset.py index 2ef4860f..2f71cbf8 100644 --- a/src/algokit_utils/_legacy_v2/asset.py +++ b/src/algokit_utils/_legacy_v2/asset.py @@ -10,7 +10,7 @@ from enum import Enum, auto -from algokit_utils._legacy_v2.models import Account +from algokit_utils.models.account import Account __all__ = ["opt_in", "opt_out"] logger = logging.getLogger(__name__) diff --git a/src/algokit_utils/_legacy_v2/deploy.py b/src/algokit_utils/_legacy_v2/deploy.py index 561ce413..ed0bd0e5 100644 --- a/src/algokit_utils/_legacy_v2/deploy.py +++ b/src/algokit_utils/_legacy_v2/deploy.py @@ -11,6 +11,7 @@ from algosdk.atomic_transaction_composer import AtomicTransactionComposer, TransactionSigner from algosdk.logic import get_application_address from algosdk.transaction import StateSchema +from deprecated import deprecated from algokit_utils._legacy_v2.application_specification import ( ApplicationSpecification, @@ -21,10 +22,11 @@ from algokit_utils._legacy_v2.models import ( ABIArgsDict, ABIMethod, - Account, CreateCallParameters, TransactionResponse, ) +from algokit_utils.applications.app_manager import AppManager +from algokit_utils.models.account import Account if TYPE_CHECKING: from algosdk.v2client.algod import AlgodClient @@ -185,7 +187,7 @@ def get_creator_apps(indexer: "IndexerClient", creator_account: Account | str) - while True: response = indexer.lookup_account_application_by_creator( creator_address, limit=DEFAULT_INDEXER_MAX_API_RESOURCES_PER_ACCOUNT, next_page=token - ) # type: ignore[no-untyped-call] + ) if "message" in response: # an error occurred raise Exception(f"Error querying applications for {creator_address}: {response}") for app in response["applications"]: @@ -199,7 +201,7 @@ def get_creator_apps(indexer: "IndexerClient", creator_account: Account | str) - address=creator_address, address_role="sender", note_prefix=NOTE_PREFIX.encode("utf-8"), - ) # type: ignore[no-untyped-call] + ) transactions: list[dict] = search_transactions_response["transactions"] if not transactions: continue @@ -236,7 +238,7 @@ def get_creator_apps(indexer: "IndexerClient", creator_account: Account | str) - def _state_schema(schema: dict[str, int]) -> StateSchema: - return StateSchema(schema.get("num-uint", 0), schema.get("num-byte-slice", 0)) # type: ignore[no-untyped-call] + return StateSchema(schema.get("num-uint", 0), schema.get("num-byte-slice", 0)) def _describe_schema_breaks(prefix: str, from_schema: StateSchema, to_schema: StateSchema) -> Iterable[str]: @@ -288,33 +290,6 @@ def _is_valid_token_character(char: str) -> bool: return char.isalnum() or char == "_" -def _replace_template_variable(program_lines: list[str], template_variable: str, value: str) -> tuple[list[str], int]: - result: list[str] = [] - match_count = 0 - token = f"TMPL_{template_variable}" - token_idx_offset = len(value) - len(token) - for line in program_lines: - comment_idx = _find_unquoted_string(line, "//") - if comment_idx is None: - comment_idx = len(line) - code = line[:comment_idx] - comment = line[comment_idx:] - trailing_idx = 0 - while True: - token_idx = _find_template_token(code, token, trailing_idx) - if token_idx is None: - break - - trailing_idx = token_idx + len(token) - prefix = code[:token_idx] - suffix = code[trailing_idx:] - code = f"{prefix}{value}{suffix}" - match_count += 1 - trailing_idx += token_idx_offset - result.append(code + comment) - return result, match_count - - def add_deploy_template_variables( template_values: TemplateValueDict, allow_update: bool | None, allow_delete: bool | None ) -> None: @@ -437,6 +412,7 @@ def check_template_variables(approval_program: str, template_values: TemplateVal logger.warning(f"{tmpl_variable} not found in approval program, but variable was provided") +@deprecated(reason="Use `AppManager.replace_template_variables` instead", version="3.0.0") def replace_template_variables(program: str, template_values: TemplateValueMapping) -> str: """Replaces `TMPL_*` variables in `program` with `template_values` @@ -444,23 +420,7 @@ def replace_template_variables(program: str, template_values: TemplateValueMappi `template_values` keys should *NOT* be prefixed with `TMPL_` ``` """ - program_lines = program.splitlines() - for template_variable_name, template_value in template_values.items(): - match template_value: - case int(): - value = str(template_value) - case str(): - value = "0x" + template_value.encode("utf-8").hex() - case bytes(): - value = "0x" + template_value.hex() - case _: - raise DeploymentFailedError( - f"Unexpected template value type {template_variable_name}: {template_value.__class__}" - ) - - program_lines, matches = _replace_template_variable(program_lines, template_variable_name, value) - - return "\n".join(program_lines) + return AppManager.replace_template_variables(program, template_values) def has_template_vars(app_spec: ApplicationSpecification) -> bool: diff --git a/src/algokit_utils/_legacy_v2/models.py b/src/algokit_utils/_legacy_v2/models.py index cc5d34d2..d20bed83 100644 --- a/src/algokit_utils/_legacy_v2/models.py +++ b/src/algokit_utils/_legacy_v2/models.py @@ -2,23 +2,22 @@ from collections.abc import Sequence from typing import Any, Generic, Protocol, TypeAlias, TypedDict, TypeVar -import algosdk.account from algosdk import transaction from algosdk.abi import Method from algosdk.atomic_transaction_composer import ( - AccountTransactionSigner, AtomicTransactionResponse, SimulateAtomicTransactionResponse, TransactionSigner, ) -from algosdk.encoding import decode_address from deprecated import deprecated +# Imports from latest sdk version that rely on models previously used in legacy v2 (but moved to root models/*) + + __all__ = [ "ABIArgsDict", "ABIMethod", "ABITransactionResponse", - "Account", "CreateCallParameters", "CreateCallParametersDict", "CreateTransactionParameters", @@ -31,37 +30,6 @@ ReturnType = TypeVar("ReturnType") -@dataclasses.dataclass(kw_only=True) -class Account: - """Holds the private_key and address for an account""" - - private_key: str - """Base64 encoded private key""" - address: str = dataclasses.field(default="") - """Address for this account""" - - def __post_init__(self) -> None: - if not self.address: - self.address = algosdk.account.address_from_private_key(self.private_key) # type: ignore[no-untyped-call] - - @property - def public_key(self) -> bytes: - """The public key for this account""" - public_key = decode_address(self.address) # type: ignore[no-untyped-call] - assert isinstance(public_key, bytes) - return public_key - - @property - def signer(self) -> AccountTransactionSigner: - """An AccountTransactionSigner for this account""" - return AccountTransactionSigner(self.private_key) - - @staticmethod - def new_account() -> "Account": - private_key, address = algosdk.account.generate_account() # type: ignore[no-untyped-call] - return Account(private_key=private_key) - - @dataclasses.dataclass(kw_only=True) class TransactionResponse: """Response for a non ABI call""" diff --git a/src/algokit_utils/_legacy_v2/network_clients.py b/src/algokit_utils/_legacy_v2/network_clients.py index 2de270da..b1bcc2cb 100644 --- a/src/algokit_utils/_legacy_v2/network_clients.py +++ b/src/algokit_utils/_legacy_v2/network_clients.py @@ -70,7 +70,7 @@ def get_kmd_client(config: AlgoClientConfig | None = None) -> KMDClient: If no configuration provided will use environment variables `KMD_SERVER`, `KMD_PORT` and `KMD_TOKEN`""" config = config or _get_config_from_environment("KMD") - return KMDClient(config.token, config.server) # type: ignore[no-untyped-call] + return KMDClient(config.token, config.server) def get_indexer_client(config: AlgoClientConfig | None = None) -> IndexerClient: @@ -79,7 +79,7 @@ def get_indexer_client(config: AlgoClientConfig | None = None) -> IndexerClient: If no configuration provided will use environment variables `INDEXER_SERVER`, `INDEXER_PORT` and `INDEXER_TOKEN`""" config = config or _get_config_from_environment("INDEXER") headers = {"X-Indexer-API-Token": config.token} - return IndexerClient(config.token, config.server, headers) # type: ignore[no-untyped-call] + return IndexerClient(config.token, config.server, headers) def is_localnet(client: AlgodClient) -> bool: @@ -109,7 +109,7 @@ def get_kmd_client_from_algod_client(client: AlgodClient) -> KMDClient: # (e.g. same token and server as algod and port 4002 by default) port = os.getenv("KMD_PORT", "4002") server = _replace_kmd_port(client.algod_address, port) - return KMDClient(client.algod_token, server) # type: ignore[no-untyped-call] + return KMDClient(client.algod_token, server) def _replace_kmd_port(address: str, port: str) -> str: diff --git a/src/algokit_utils/accounts/account_manager.py b/src/algokit_utils/accounts/account_manager.py index a2527c6c..d4d95d19 100644 --- a/src/algokit_utils/accounts/account_manager.py +++ b/src/algokit_utils/accounts/account_manager.py @@ -109,7 +109,7 @@ def random(self) -> AddressAndSigner: :return: The account """ - (sk, addr) = generate_account() # type: ignore[no-untyped-call] + (sk, addr) = generate_account() signer = AccountTransactionSigner(sk) self.set_signer(addr, signer) diff --git a/src/algokit_utils/applications/app_manager.py b/src/algokit_utils/applications/app_manager.py new file mode 100644 index 00000000..91b0a407 --- /dev/null +++ b/src/algokit_utils/applications/app_manager.py @@ -0,0 +1,355 @@ +import base64 +from collections.abc import Mapping +from dataclasses import dataclass +from enum import IntEnum +from typing import Any, TypeAlias + +import algosdk +import algosdk.atomic_transaction_composer +import algosdk.box_reference +from algosdk.atomic_transaction_composer import AccountTransactionSigner +from algosdk.logic import get_application_address +from algosdk.v2client import algod + +from algokit_utils.models.abi import ABIValue +from algokit_utils.models.application import DELETABLE_TEMPLATE_NAME, UPDATABLE_TEMPLATE_NAME + + +@dataclass(frozen=True) +class BoxName: + name: str + name_raw: bytes + name_base64: str + + +@dataclass(frozen=True) +class AppState: + key_raw: bytes + key_base64: str + value_raw: bytes | None + value_base64: str | None + value: str | int + + +class DataTypeFlag(IntEnum): + BYTES = 1 + UINT = 2 + + +TealTemplateParams: TypeAlias = Mapping[str, str | int | bytes] | dict[str, str | int | bytes] + + +@dataclass(frozen=True) +class AppInformation: + app_id: int + app_address: str + approval_program: bytes + clear_state_program: bytes + creator: str + global_state: dict[str, AppState] + local_ints: int + local_byte_slices: int + global_ints: int + global_byte_slices: int + extra_program_pages: int | None + + +@dataclass(frozen=True) +class CompiledTeal: + teal: str + compiled: bytes + compiled_hash: str + compiled_base64_to_bytes: bytes + source_map: dict | None + + +BoxIdentifier = str | bytes | AccountTransactionSigner + + +def _is_valid_token_character(char: str) -> bool: + return char.isalnum() or char == "_" + + +def _last_token_base64(line: str, idx: int) -> bool: + try: + *_, last = line[:idx].split() + except ValueError: + return False + return last in ("base64", "b64") + + +def _find_template_token(line: str, token: str, start: int = 0, end: int = -1) -> int | None: + """Find the first template token within a line of TEAL. Only matches outside of quotes are returned. + Only full token matches are returned, i.e. TMPL_STR will not match against TMPL_STRING + Returns None if not found""" + if end < 0: + end = len(line) + + idx = start + while idx < end: + token_idx = _find_unquoted_string(line, token, idx, end) + if token_idx is None: + break + trailing_idx = token_idx + len(token) + if (token_idx == 0 or not _is_valid_token_character(line[token_idx - 1])) and ( # word boundary at start + trailing_idx >= len(line) or not _is_valid_token_character(line[trailing_idx]) # word boundary at end + ): + return token_idx + idx = trailing_idx + return None + + +def _find_unquoted_string(line: str, token: str, start: int = 0, end: int = -1) -> int | None: + """Find the first string within a line of TEAL. Only matches outside of quotes and base64 are returned. + Returns None if not found""" + + if end < 0: + end = len(line) + idx = start + in_quotes = in_base64 = False + while idx < end: + current_char = line[idx] + match current_char: + # enter base64 + case " " | "(" if not in_quotes and _last_token_base64(line, idx): + in_base64 = True + # exit base64 + case " " | ")" if not in_quotes and in_base64: + in_base64 = False + # escaped char + case "\\" if in_quotes: + # skip next character + idx += 1 + # quote boundary + case '"': + in_quotes = not in_quotes + # can test for match + case _ if not in_quotes and not in_base64 and line.startswith(token, idx): + # only match if not in quotes and string matches + return idx + idx += 1 + return None + + +def _replace_template_variable(program_lines: list[str], template_variable: str, value: str) -> tuple[list[str], int]: + result: list[str] = [] + match_count = 0 + token = f"TMPL_{template_variable}" + token_idx_offset = len(value) - len(token) + for line in program_lines: + comment_idx = _find_unquoted_string(line, "//") + if comment_idx is None: + comment_idx = len(line) + code = line[:comment_idx] + comment = line[comment_idx:] + trailing_idx = 0 + while True: + token_idx = _find_template_token(code, token, trailing_idx) + if token_idx is None: + break + + trailing_idx = token_idx + len(token) + prefix = code[:token_idx] + suffix = code[trailing_idx:] + code = f"{prefix}{value}{suffix}" + match_count += 1 + trailing_idx += token_idx_offset + result.append(code + comment) + return result, match_count + + +class AppManager: + def __init__(self, algod_client: algod.AlgodClient): + self._algod = algod_client + self._compilation_results: dict[str, CompiledTeal] = {} + + def compile_teal(self, teal_code: str) -> CompiledTeal: + if teal_code in self._compilation_results: + return self._compilation_results[teal_code] + + compiled = self._algod.compile(teal_code, source_map=True) + result = CompiledTeal( + teal=teal_code, + compiled=compiled["result"], + compiled_hash=compiled["hash"], + compiled_base64_to_bytes=base64.b64decode(compiled["result"]), + source_map=compiled.get("sourcemap"), + ) + self._compilation_results[teal_code] = result + return result + + def compile_teal_template( + self, + teal_template_code: str, + template_params: TealTemplateParams | None = None, + deployment_metadata: dict[str, bool] | None = None, + ) -> CompiledTeal: + teal_code = AppManager.strip_teal_comments(teal_template_code) + teal_code = AppManager.replace_template_variables(teal_code, template_params or {}) + + if deployment_metadata: + teal_code = AppManager.replace_teal_template_deploy_time_control_params(teal_code, deployment_metadata) + + return self.compile_teal(teal_code) + + def get_compilation_result(self, teal_code: str) -> CompiledTeal | None: + return self._compilation_results.get(teal_code) + + def get_by_id(self, app_id: int) -> AppInformation: + app = self._algod.application_info(app_id) + assert isinstance(app, dict) + app_params = app["params"] + + return AppInformation( + app_id=app_id, + app_address=get_application_address(app_id), + approval_program=base64.b64decode(app_params["approval-program"]), + clear_state_program=base64.b64decode(app_params["clear-state-program"]), + creator=app_params["creator"], + local_ints=app_params["local-state-schema"]["num-uint"], + local_byte_slices=app_params["local-state-schema"]["num-byte-slice"], + global_ints=app_params["global-state-schema"]["num-uint"], + global_byte_slices=app_params["global-state-schema"]["num-byte-slice"], + extra_program_pages=app_params.get("extra-program-pages", 0), + global_state=self.decode_app_state(app_params.get("global-state", [])), + ) + + def get_global_state(self, app_id: int) -> dict[str, AppState]: + return self.get_by_id(app_id).global_state + + def get_local_state(self, app_id: int, address: str) -> dict[str, AppState]: + app_info = self._algod.account_application_info(address, app_id) + assert isinstance(app_info, dict) + if not app_info.get("app-local-state", {}).get("key-value"): + raise ValueError("Couldn't find local state") + return self.decode_app_state(app_info["app-local-state"]["key-value"]) + + def get_box_names(self, app_id: int) -> list[BoxName]: + box_result = self._algod.application_boxes(app_id) + assert isinstance(box_result, dict) + return [ + BoxName( + name_raw=base64.b64decode(b["name"]), + name_base64=b["name"], + name=base64.b64decode(b["name"]).decode("utf-8"), + ) + for b in box_result["boxes"] + ] + + def get_box_value(self, app_id: int, box_name: BoxIdentifier) -> bytes: + name = b"" + if isinstance(box_name, str): + name = box_name.encode("utf-8") + elif isinstance(box_name, bytes): + name = box_name + elif isinstance(box_name, AccountTransactionSigner): + name = algosdk.encoding.decode_address(algosdk.account.address_from_private_key(box_name.private_key)) + else: + raise ValueError(f"Invalid box identifier type: {type(box_name)}") + + box_result = self._algod.application_box_by_name(app_id, name) + assert isinstance(box_result, dict) + return base64.b64decode(box_result["value"]) + + def get_box_values(self, app_id: int, box_names: list[BoxIdentifier]) -> list[bytes]: + return [self.get_box_value(app_id, box_name) for box_name in box_names] + + def get_box_value_from_abi_type( + self, app_id: int, box_name: BoxIdentifier, abi_type: algosdk.abi.ABIType + ) -> ABIValue: + value = self.get_box_value(app_id, box_name) + try: + return abi_type.decode(value) # type: ignore[no-any-return] + except Exception as e: + raise ValueError(f"Failed to decode box value {value.decode('utf-8')} with ABI type {abi_type}") from e + + def get_box_values_from_abi_type( + self, app_id: int, box_names: list[BoxIdentifier], abi_type: algosdk.abi.ABIType + ) -> list[ABIValue]: + return [self.get_box_value_from_abi_type(app_id, box_name, abi_type) for box_name in box_names] + + @staticmethod + def decode_app_state(state: list[dict[str, Any]]) -> dict[str, AppState]: + state_values: dict[str, AppState] = {} + + for state_val in state: + key_base64 = state_val["key"] + key_raw = base64.b64decode(key_base64) + key = key_raw.decode("utf-8") + teal_value = state_val["value"] + + data_type_flag = teal_value.get("action", teal_value.get("type")) + + if data_type_flag == DataTypeFlag.BYTES: + value_base64 = teal_value.get("bytes", "") + value_raw = base64.b64decode(value_base64) + state_values[key] = AppState( + key_raw=key_raw, + key_base64=key_base64, + value_raw=value_raw, + value_base64=value_base64, + value=value_raw.decode("utf-8"), + ) + elif data_type_flag == DataTypeFlag.UINT: + value = teal_value.get("uint", 0) + state_values[key] = AppState( + key_raw=key_raw, + key_base64=key_base64, + value_raw=None, + value_base64=None, + value=int(value), + ) + else: + raise ValueError(f"Received unknown state data type of {data_type_flag}") + + return state_values + + @staticmethod + def replace_template_variables(program: str, template_values: TealTemplateParams) -> str: + program_lines = program.splitlines() + for template_variable_name, template_value in template_values.items(): + match template_value: + case int(): + value = str(template_value) + case str(): + value = "0x" + template_value.encode("utf-8").hex() + case bytes(): + value = "0x" + template_value.hex() + case _: + raise ValueError( + f"Unexpected template value type {template_variable_name}: {template_value.__class__}" + ) + + program_lines, _ = _replace_template_variable(program_lines, template_variable_name, value) + + return "\n".join(program_lines) + + @staticmethod + def replace_teal_template_deploy_time_control_params(teal_template_code: str, params: dict[str, bool]) -> str: + if params.get("updatable") is not None: + if UPDATABLE_TEMPLATE_NAME not in teal_template_code: + raise ValueError( + f"Deploy-time updatability control requested for app deployment, but {UPDATABLE_TEMPLATE_NAME} " + "not present in TEAL code" + ) + teal_template_code = teal_template_code.replace(UPDATABLE_TEMPLATE_NAME, str(int(params["updatable"]))) + + if params.get("deletable") is not None: + if DELETABLE_TEMPLATE_NAME not in teal_template_code: + raise ValueError( + f"Deploy-time deletability control requested for app deployment, but {DELETABLE_TEMPLATE_NAME} " + "not present in TEAL code" + ) + teal_template_code = teal_template_code.replace(DELETABLE_TEMPLATE_NAME, str(int(params["deletable"]))) + + return teal_template_code + + @staticmethod + def strip_teal_comments(teal_code: str) -> str: + def _strip_comment(line: str) -> str: + comment_idx = _find_unquoted_string(line, "//") + if comment_idx is None: + return line + return line[:comment_idx].rstrip() + + return "\n".join(_strip_comment(line) for line in teal_code.splitlines()) diff --git a/src/algokit_utils/assets/asset_manager.py b/src/algokit_utils/assets/asset_manager.py new file mode 100644 index 00000000..4bef4802 --- /dev/null +++ b/src/algokit_utils/assets/asset_manager.py @@ -0,0 +1,2 @@ +class AssetManager: + """A manager for Algorand assets""" diff --git a/src/algokit_utils/clients/algorand_client.py b/src/algokit_utils/clients/algorand_client.py index 7e02e20a..f4851daf 100644 --- a/src/algokit_utils/clients/algorand_client.py +++ b/src/algokit_utils/clients/algorand_client.py @@ -9,6 +9,8 @@ from typing_extensions import Self from algokit_utils.accounts.account_manager import AccountManager +from algokit_utils.applications.app_manager import AppManager +from algokit_utils.assets.asset_manager import AssetManager from algokit_utils.clients.client_manager import AlgoSdkClients, ClientManager from algokit_utils.network_clients import ( AlgoClientConfigs, @@ -20,29 +22,31 @@ ) from algokit_utils.transactions.transaction_composer import ( AppCallParams, + AppMethodCallParams, AssetConfigParams, AssetCreateParams, AssetDestroyParams, AssetFreezeParams, AssetOptInParams, AssetTransferParams, - MethodCallParams, - OnlineKeyRegParams, - PayParams, + OnlineKeyRegistrationParams, + PaymentParams, TransactionComposer, ) +from algokit_utils.transactions.transaction_creator import AlgorandClientTransactionCreator +from algokit_utils.transactions.transaction_sender import AlgorandClientTransactionSender __all__ = [ "AlgorandClient", "AssetCreateParams", "AssetOptInParams", - "MethodCallParams", - "PayParams", + "AppMethodCallParams", + "PaymentParams", "AssetFreezeParams", "AssetConfigParams", "AssetDestroyParams", "AppCallParams", - "OnlineKeyRegParams", + "OnlineKeyRegistrationParams", "AssetTransferParams", ] @@ -53,15 +57,15 @@ class AlgorandClientSendMethods: Methods used to send a transaction to the network and wait for confirmation """ - payment: Callable[[PayParams], dict[str, Any]] + payment: Callable[[PaymentParams], dict[str, Any]] asset_create: Callable[[AssetCreateParams], dict[str, Any]] asset_config: Callable[[AssetConfigParams], dict[str, Any]] asset_freeze: Callable[[AssetFreezeParams], dict[str, Any]] asset_destroy: Callable[[AssetDestroyParams], dict[str, Any]] asset_transfer: Callable[[AssetTransferParams], dict[str, Any]] app_call: Callable[[AppCallParams], dict[str, Any]] - online_key_reg: Callable[[OnlineKeyRegParams], dict[str, Any]] - method_call: Callable[[MethodCallParams], dict[str, Any]] + online_key_reg: Callable[[OnlineKeyRegistrationParams], dict[str, Any]] + method_call: Callable[[AppMethodCallParams], dict[str, Any]] asset_opt_in: Callable[[AssetOptInParams], dict[str, Any]] @@ -71,15 +75,15 @@ class AlgorandClientTransactionMethods: Methods used to form a transaction without signing or sending to the network """ - payment: Callable[[PayParams], Transaction] + payment: Callable[[PaymentParams], Transaction] asset_create: Callable[[AssetCreateParams], Transaction] asset_config: Callable[[AssetConfigParams], Transaction] asset_freeze: Callable[[AssetFreezeParams], Transaction] asset_destroy: Callable[[AssetDestroyParams], Transaction] asset_transfer: Callable[[AssetTransferParams], Transaction] app_call: Callable[[AppCallParams], Transaction] - online_key_reg: Callable[[OnlineKeyRegParams], Transaction] - method_call: Callable[[MethodCallParams], list[Transaction]] + online_key_reg: Callable[[OnlineKeyRegistrationParams], Transaction] + method_call: Callable[[AppMethodCallParams], list[Transaction]] asset_opt_in: Callable[[AssetOptInParams], Transaction] @@ -89,6 +93,15 @@ class AlgorandClient: def __init__(self, config: AlgoClientConfigs | AlgoSdkClients): self._client_manager: ClientManager = ClientManager(config) self._account_manager: AccountManager = AccountManager(self._client_manager) + self._asset_manager: AssetManager = AssetManager() # TODO: implement + self._app_manager: AppManager = AppManager(self._client_manager.algod) # TODO: implement + self._transaction_sender = AlgorandClientTransactionSender( + new_group=lambda: self.new_group(), + asset_manager=self._asset_manager, + app_manager=self._app_manager, + algod_client=self._client_manager.algod, + ) + self._transaction_creator = AlgorandClientTransactionCreator() # TODO: implement self._cached_suggested_params: SuggestedParams | None = None self._cached_suggested_params_expiry: float | None = None @@ -187,53 +200,14 @@ def new_group(self) -> TransactionComposer: ) @property - def send(self) -> AlgorandClientSendMethods: + def send(self) -> AlgorandClientTransactionSender: """Methods for sending a transaction and waiting for confirmation""" - return AlgorandClientSendMethods( - payment=lambda params: self._unwrap_single_send_result(self.new_group().add_payment(params).execute()), - asset_create=lambda params: self._unwrap_single_send_result( - self.new_group().add_asset_create(params).execute() - ), - asset_config=lambda params: self._unwrap_single_send_result( - self.new_group().add_asset_config(params).execute() - ), - asset_freeze=lambda params: self._unwrap_single_send_result( - self.new_group().add_asset_freeze(params).execute() - ), - asset_destroy=lambda params: self._unwrap_single_send_result( - self.new_group().add_asset_destroy(params).execute() - ), - asset_transfer=lambda params: self._unwrap_single_send_result( - self.new_group().add_asset_transfer(params).execute() - ), - app_call=lambda params: self._unwrap_single_send_result(self.new_group().add_app_call(params).execute()), - online_key_reg=lambda params: self._unwrap_single_send_result( - self.new_group().add_online_key_reg(params).execute() - ), - method_call=lambda params: self._unwrap_single_send_result( - self.new_group().add_method_call(params).execute() - ), - asset_opt_in=lambda params: self._unwrap_single_send_result( - self.new_group().add_asset_opt_in(params).execute() - ), - ) + return self._transaction_sender @property - def transactions(self) -> AlgorandClientTransactionMethods: + def create_transaction(self) -> AlgorandClientTransactionCreator: """Methods for building transactions""" - - return AlgorandClientTransactionMethods( - payment=lambda params: self.new_group().add_payment(params).build_group()[0].txn, - asset_create=lambda params: self.new_group().add_asset_create(params).build_group()[0].txn, - asset_config=lambda params: self.new_group().add_asset_config(params).build_group()[0].txn, - asset_freeze=lambda params: self.new_group().add_asset_freeze(params).build_group()[0].txn, - asset_destroy=lambda params: self.new_group().add_asset_destroy(params).build_group()[0].txn, - asset_transfer=lambda params: self.new_group().add_asset_transfer(params).build_group()[0].txn, - app_call=lambda params: self.new_group().add_app_call(params).build_group()[0].txn, - online_key_reg=lambda params: self.new_group().add_online_key_reg(params).build_group()[0].txn, - method_call=lambda params: [txn.txn for txn in self.new_group().add_method_call(params).build_group()], - asset_opt_in=lambda params: self.new_group().add_asset_opt_in(params).build_group()[0].txn, - ) + return self._transaction_creator @staticmethod def default_local_net() -> "AlgorandClient": diff --git a/src/algokit_utils/clients/models.py b/src/algokit_utils/clients/models.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/algokit_utils/models/__init__.py b/src/algokit_utils/models/__init__.py index bcffc093..baf4664d 100644 --- a/src/algokit_utils/models/__init__.py +++ b/src/algokit_utils/models/__init__.py @@ -1 +1,4 @@ from algokit_utils._legacy_v2.models import * # noqa: F403 + +from .abi import * # noqa: F403 +from .account import * # noqa: F403 diff --git a/src/algokit_utils/models/abi.py b/src/algokit_utils/models/abi.py new file mode 100644 index 00000000..767eed09 --- /dev/null +++ b/src/algokit_utils/models/abi.py @@ -0,0 +1,4 @@ +ABIPrimitiveValue = bool | int | str | bytes | bytearray + +# NOTE: This is present in js-algorand-sdk, but sadly not in untyped py-algorand-sdk +ABIValue = ABIPrimitiveValue | list["ABIValue"] | dict[str, "ABIValue"] diff --git a/src/algokit_utils/models/account.py b/src/algokit_utils/models/account.py new file mode 100644 index 00000000..3014b7af --- /dev/null +++ b/src/algokit_utils/models/account.py @@ -0,0 +1,35 @@ +import dataclasses + +import algosdk +from algosdk.atomic_transaction_composer import AccountTransactionSigner + + +@dataclasses.dataclass(kw_only=True) +class Account: + """Holds the private_key and address for an account""" + + private_key: str + """Base64 encoded private key""" + address: str = dataclasses.field(default="") + """Address for this account""" + + def __post_init__(self) -> None: + if not self.address: + self.address = algosdk.account.address_from_private_key(self.private_key) + + @property + def public_key(self) -> bytes: + """The public key for this account""" + public_key = algosdk.encoding.decode_address(self.address) + assert isinstance(public_key, bytes) + return public_key + + @property + def signer(self) -> AccountTransactionSigner: + """An AccountTransactionSigner for this account""" + return AccountTransactionSigner(self.private_key) + + @staticmethod + def new_account() -> "Account": + private_key, address = algosdk.account.generate_account() + return Account(private_key=private_key) diff --git a/src/algokit_utils/models/amount.py b/src/algokit_utils/models/amount.py new file mode 100644 index 00000000..ac86cd3b --- /dev/null +++ b/src/algokit_utils/models/amount.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +from decimal import Decimal + +import algosdk +from typing_extensions import Self + + +class AlgoAmount: + def __init__(self, amount: dict[str, int | Decimal]): + if "microAlgos" in amount: + self.amount_in_micro_algo = int(amount["microAlgos"]) + elif "microAlgo" in amount: + self.amount_in_micro_algo = int(amount["microAlgo"]) + elif "algos" in amount: + self.amount_in_micro_algo = algosdk.util.algos_to_microalgos(float(amount["algos"])) + elif "algo" in amount: + self.amount_in_micro_algo = algosdk.util.algos_to_microalgos(float(amount["algo"])) + else: + raise ValueError("Invalid amount provided") + + @property + def micro_algos(self) -> int: + return self.amount_in_micro_algo + + @property + def micro_algo(self) -> int: + return self.amount_in_micro_algo + + @property + def algos(self) -> int | Decimal: + return algosdk.util.microalgos_to_algos(self.amount_in_micro_algo) # type: ignore[no-any-return] + + @property + def algo(self) -> int | Decimal: + return algosdk.util.microalgos_to_algos(self.amount_in_micro_algo) # type: ignore[no-any-return] + + @staticmethod + def from_algos(amount: int | Decimal) -> AlgoAmount: + return AlgoAmount({"algos": amount}) + + @staticmethod + def from_algo(amount: int | Decimal) -> AlgoAmount: + return AlgoAmount({"algo": amount}) + + @staticmethod + def from_micro_algos(amount: int | Decimal) -> AlgoAmount: + return AlgoAmount({"microAlgos": amount}) + + @staticmethod + def from_micro_algo(amount: int | Decimal) -> AlgoAmount: + return AlgoAmount({"microAlgo": amount}) + + def __str__(self) -> str: + """Return a string representation of the amount.""" + return f"{self.micro_algo:,} µALGO" + + def __int__(self) -> int: + """Return the amount as an integer number of microAlgos.""" + return self.micro_algos + + def __add__(self, other: int | Decimal | AlgoAmount) -> AlgoAmount: + if isinstance(other, AlgoAmount): + total_micro_algos = self.micro_algos + other.micro_algos + elif isinstance(other, (int | Decimal)): + total_micro_algos = self.micro_algos + int(other) + else: + raise TypeError(f"Unsupported operand type(s) for +: 'AlgoAmount' and '{type(other).__name__}'") + return AlgoAmount.from_micro_algos(total_micro_algos) + + def __radd__(self, other: int | Decimal) -> AlgoAmount: + return self.__add__(other) + + def __iadd__(self, other: int | Decimal | AlgoAmount) -> Self: + if isinstance(other, AlgoAmount): + self.amount_in_micro_algo += other.micro_algos + elif isinstance(other, (int | Decimal)): + self.amount_in_micro_algo += int(other) + else: + raise TypeError(f"Unsupported operand type(s) for +: 'AlgoAmount' and '{type(other).__name__}'") + return self + + def __eq__(self, other: object) -> bool: + if isinstance(other, AlgoAmount): + return self.amount_in_micro_algo == other.amount_in_micro_algo + elif isinstance(other, int | Decimal): + return self.amount_in_micro_algo == int(other) + raise TypeError(f"Unsupported operand type(s) for ==: 'AlgoAmount' and '{type(other).__name__}'") + + def __ne__(self, other: object) -> bool: + if isinstance(other, AlgoAmount): + return self.amount_in_micro_algo != other.amount_in_micro_algo + elif isinstance(other, int | Decimal): + return self.amount_in_micro_algo != int(other) + raise TypeError(f"Unsupported operand type(s) for !=: 'AlgoAmount' and '{type(other).__name__}'") + + def __lt__(self, other: object) -> bool: + if isinstance(other, AlgoAmount): + return self.amount_in_micro_algo < other.amount_in_micro_algo + elif isinstance(other, int | Decimal): + return self.amount_in_micro_algo < int(other) + raise TypeError(f"Unsupported operand type(s) for <: 'AlgoAmount' and '{type(other).__name__}'") + + def __le__(self, other: object) -> bool: + if isinstance(other, AlgoAmount): + return self.amount_in_micro_algo <= other.amount_in_micro_algo + elif isinstance(other, int | Decimal): + return self.amount_in_micro_algo <= int(other) + raise TypeError(f"Unsupported operand type(s) for <=: 'AlgoAmount' and '{type(other).__name__}'") + + def __gt__(self, other: object) -> bool: + if isinstance(other, AlgoAmount): + return self.amount_in_micro_algo > other.amount_in_micro_algo + elif isinstance(other, int | Decimal): + return self.amount_in_micro_algo > int(other) + raise TypeError(f"Unsupported operand type(s) for >: 'AlgoAmount' and '{type(other).__name__}'") + + def __ge__(self, other: object) -> bool: + if isinstance(other, AlgoAmount): + return self.amount_in_micro_algo >= other.amount_in_micro_algo + elif isinstance(other, int | Decimal): + return self.amount_in_micro_algo >= int(other) + raise TypeError(f"Unsupported operand type(s) for >=: 'AlgoAmount' and '{type(other).__name__}'") diff --git a/src/algokit_utils/models/application.py b/src/algokit_utils/models/application.py new file mode 100644 index 00000000..c68e78af --- /dev/null +++ b/src/algokit_utils/models/application.py @@ -0,0 +1,5 @@ +UPDATABLE_TEMPLATE_NAME = "TMPL_UPDATABLE" +"""The name of the TEAL template variable for deploy-time immutability control.""" + +DELETABLE_TEMPLATE_NAME = "TMPL_DELETABLE" +"""The name of the TEAL template variable for deploy-time permanence control.""" diff --git a/src/algokit_utils/models/common.py b/src/algokit_utils/models/common.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/algokit_utils/transactions/models.py b/src/algokit_utils/transactions/models.py index e69de29b..251bbf96 100644 --- a/src/algokit_utils/transactions/models.py +++ b/src/algokit_utils/transactions/models.py @@ -0,0 +1,30 @@ +from typing import Any, Literal, TypedDict + + +# Define specific types for different formats +class BaseArc2Note(TypedDict): + """Base ARC-0002 transaction note structure""" + + dapp_name: str + + +class StringFormatArc2Note(BaseArc2Note): + """ARC-0002 note for string-based formats (m/b/u)""" + + format: Literal["m", "b", "u"] + data: str + + +class JsonFormatArc2Note(BaseArc2Note): + """ARC-0002 note for JSON format""" + + format: Literal["j"] + data: str | dict[str, Any] | list[Any] | int | None + + +# Combined type for all valid ARC-0002 notes +# See: https://github.com/algorandfoundation/ARCs/blob/main/ARCs/arc-0002.md +Arc2TransactionNote = StringFormatArc2Note | JsonFormatArc2Note + +TransactionNoteData = str | None | int | list[Any] | dict[str, Any] +TransactionNote = bytes | TransactionNoteData | Arc2TransactionNote diff --git a/src/algokit_utils/transactions/transaction_composer.py b/src/algokit_utils/transactions/transaction_composer.py index 254b2a30..2d36c06d 100644 --- a/src/algokit_utils/transactions/transaction_composer.py +++ b/src/algokit_utils/transactions/transaction_composer.py @@ -1,18 +1,33 @@ -from collections.abc import Callable +from __future__ import annotations + +import math from dataclasses import dataclass -from typing import Union +from typing import TYPE_CHECKING, Union import algosdk -from algosdk.abi import Method +import algosdk.atomic_transaction_composer from algosdk.atomic_transaction_composer import ( AtomicTransactionComposer, - AtomicTransactionResponse, TransactionSigner, TransactionWithSigner, ) -from algosdk.box_reference import BoxReference from algosdk.transaction import OnComplete -from algosdk.v2client.algod import AlgodClient +from deprecated import deprecated + +from algokit_utils._debugging import simulate_and_persist_response, simulate_response +from algokit_utils.applications.app_manager import AppManager +from algokit_utils.config import config + +if TYPE_CHECKING: + from collections.abc import Callable + + from algosdk.abi import Method + from algosdk.box_reference import BoxReference + from algosdk.v2client.algod import AlgodClient + + from algokit_utils.models.abi import ABIValue + from algokit_utils.models.amount import AlgoAmount + from algokit_utils.transactions.models import Arc2TransactionNote @dataclass(frozen=True) @@ -22,26 +37,44 @@ class SenderParam: @dataclass(frozen=True) class CommonTxnParams: + """ + Common transaction parameters. + + :param signer: The function used to sign transactions. + :param rekey_to: Change the signing key of the sender to the given address. + :param note: Note to attach to the transaction. + :param lease: Prevent multiple transactions with the same lease being included within the validity window. + :param static_fee: The transaction fee. In most cases you want to use `extra_fee` unless setting the fee to 0 to be + covered by another transaction. + :param extra_fee: The fee to pay IN ADDITION to the suggested fee. Useful for covering inner transaction fees. + :param max_fee: Throw an error if the fee for the transaction is more than this amount. + :param validity_window: How many rounds the transaction should be valid for. + :param first_valid_round: Set the first round this transaction is valid. If left undefined, the value from algod + will be used. Only set this when you intentionally want this to be some time in the future. + :param last_valid_round: The last round this transaction is valid. It is recommended to use validity_window instead. + """ + + sender: str signer: TransactionSigner | None = None rekey_to: str | None = None note: bytes | None = None lease: bytes | None = None - static_fee: int | None = None - extra_fee: int | None = None - max_fee: int | None = None + static_fee: AlgoAmount | None = None + extra_fee: AlgoAmount | None = None + max_fee: AlgoAmount | None = None validity_window: int | None = None first_valid_round: int | None = None last_valid_round: int | None = None @dataclass(frozen=True) -class _RequiredPayTxnParams(SenderParam): +class _RequiredPaymentParams: receiver: str - amount: int + amount: AlgoAmount @dataclass(frozen=True) -class PayParams(CommonTxnParams, _RequiredPayTxnParams): +class PaymentParams(CommonTxnParams, _RequiredPaymentParams): """ Payment transaction parameters. @@ -54,31 +87,69 @@ class PayParams(CommonTxnParams, _RequiredPayTxnParams): @dataclass(frozen=True) -class _RequiredAssetCreateParams(SenderParam): +class _RequiredAssetCreateParams: total: int + asset_name: str + unit_name: str + url: str @dataclass(frozen=True) -class AssetCreateParams(CommonTxnParams, _RequiredAssetCreateParams): +class AssetCreateParams( + CommonTxnParams, + _RequiredAssetCreateParams, +): + """ + Asset creation parameters. + + :param total: The total amount of the smallest divisible unit to create. + :param decimals: The amount of decimal places the asset should have. + :param default_frozen: Whether the asset is frozen by default in the creator address. + :param manager: The address that can change the manager, reserve, clawback, and freeze addresses. + There will permanently be no manager if undefined or an empty string. + :param reserve: The address that holds the uncirculated supply. + :param freeze: The address that can freeze the asset in any account. + Freezing will be permanently disabled if undefined or an empty string. + :param clawback: The address that can clawback the asset from any account. + Clawback will be permanently disabled if undefined or an empty string. + :param unit_name: The short ticker name for the asset. + :param asset_name: The full name of the asset. + :param url: The metadata URL for the asset. + :param metadata_hash: Hash of the metadata contained in the metadata URL. + """ + decimals: int | None = None default_frozen: bool | None = None manager: str | None = None reserve: str | None = None freeze: str | None = None clawback: str | None = None - unit_name: str | None = None - asset_name: str | None = None - url: str | None = None metadata_hash: bytes | None = None @dataclass(frozen=True) -class _RequiredAssetConfigParams(SenderParam): +class _RequiredAssetConfigParams: asset_id: int @dataclass(frozen=True) -class AssetConfigParams(CommonTxnParams, _RequiredAssetConfigParams): +class AssetConfigParams( + CommonTxnParams, + _RequiredAssetConfigParams, +): + """ + Asset configuration parameters. + + :param asset_id: ID of the asset. + :param manager: The address that can change the manager, reserve, clawback, and freeze addresses. + There will permanently be no manager if undefined or an empty string. + :param reserve: The address that holds the uncirculated supply. + :param freeze: The address that can freeze the asset in any account. + Freezing will be permanently disabled if undefined or an empty string. + :param clawback: The address that can clawback the asset from any account. + Clawback will be permanently disabled if undefined or an empty string. + """ + manager: str | None = None reserve: str | None = None freeze: str | None = None @@ -86,14 +157,17 @@ class AssetConfigParams(CommonTxnParams, _RequiredAssetConfigParams): @dataclass(frozen=True) -class _RequiredAssetFreezeParams(SenderParam): +class _RequiredAssetFreezeParams: asset_id: int account: str frozen: bool @dataclass(frozen=True) -class AssetFreezeParams(CommonTxnParams, _RequiredAssetFreezeParams): +class AssetFreezeParams( + CommonTxnParams, + _RequiredAssetFreezeParams, +): """ Asset freeze parameters. @@ -104,12 +178,15 @@ class AssetFreezeParams(CommonTxnParams, _RequiredAssetFreezeParams): @dataclass(frozen=True) -class _RequiredAssetDestroyParams(SenderParam): +class _RequiredAssetDestroyParams: asset_id: int @dataclass(frozen=True) -class AssetDestroyParams(CommonTxnParams, _RequiredAssetDestroyParams): +class AssetDestroyParams( + CommonTxnParams, + _RequiredAssetDestroyParams, +): """ Asset destruction parameters. @@ -118,7 +195,7 @@ class AssetDestroyParams(CommonTxnParams, _RequiredAssetDestroyParams): @dataclass(frozen=True) -class _RequiredOnlineKeyRegParams(SenderParam): +class _RequiredOnlineKeyRegistrationParams: vote_key: str selection_key: str vote_first: int @@ -127,30 +204,63 @@ class _RequiredOnlineKeyRegParams(SenderParam): @dataclass(frozen=True) -class OnlineKeyRegParams(CommonTxnParams, _RequiredOnlineKeyRegParams): +class OnlineKeyRegistrationParams( + CommonTxnParams, + _RequiredOnlineKeyRegistrationParams, +): + """ + Online key registration parameters. + + :param vote_key: The root participation public key. + :param selection_key: The VRF public key. + :param vote_first: The first round that the participation key is valid. + Not to be confused with the `first_valid` round of the keyreg transaction. + :param vote_last: The last round that the participation key is valid. + Not to be confused with the `last_valid` round of the keyreg transaction. + :param vote_key_dilution: This is the dilution for the 2-level participation key. + It determines the interval (number of rounds) for generating new ephemeral keys. + :param state_proof_key: The 64 byte state proof public key commitment. + """ + state_proof_key: bytes | None = None @dataclass(frozen=True) -class _RequiredAssetTransferParams(SenderParam): +class _RequiredAssetTransferParams: asset_id: int amount: int receiver: str @dataclass(frozen=True) -class AssetTransferParams(CommonTxnParams, _RequiredAssetTransferParams): +class AssetTransferParams( + CommonTxnParams, + _RequiredAssetTransferParams, +): + """ + Asset transfer parameters. + + :param asset_id: ID of the asset. + :param amount: Amount of the asset to transfer (smallest divisible unit). + :param receiver: The account to send the asset to. + :param clawback_target: The account to take the asset from. + :param close_asset_to: The account to close the asset to. + """ + clawback_target: str | None = None close_asset_to: str | None = None @dataclass(frozen=True) -class _RequiredAssetOptInParams(SenderParam): +class _RequiredAssetOptInParams: asset_id: int @dataclass(frozen=True) -class AssetOptInParams(CommonTxnParams, _RequiredAssetOptInParams): +class AssetOptInParams( + CommonTxnParams, + _RequiredAssetOptInParams, +): """ Asset opt-in parameters. @@ -158,6 +268,22 @@ class AssetOptInParams(CommonTxnParams, _RequiredAssetOptInParams): """ +@dataclass(frozen=True) +class _RequiredAssetOptOutParams: + asset_id: int + creator: str + + +@dataclass(frozen=True) +class AssetOptOutParams( + CommonTxnParams, + _RequiredAssetOptOutParams, +): + """ + Asset opt-out parameters. + """ + + @dataclass(frozen=True) class AppCallParams(CommonTxnParams, SenderParam): """ @@ -166,7 +292,7 @@ class AppCallParams(CommonTxnParams, SenderParam): :param on_complete: The OnComplete action. :param app_id: ID of the application. :param approval_program: The program to execute for all OnCompletes other than ClearState. - :param clear_program: The program to execute for ClearState OnComplete. + :param clear_state_program: The program to execute for ClearState OnComplete. :param schema: The state schema for the app. This is immutable. :param args: Application arguments. :param account_references: Account references. @@ -178,8 +304,8 @@ class AppCallParams(CommonTxnParams, SenderParam): on_complete: OnComplete | None = None app_id: int | None = None - approval_program: bytes | None = None - clear_program: bytes | None = None + approval_program: str | bytes | None = None + clear_state_program: str | bytes | None = None schema: dict[str, int] | None = None args: list[bytes] | None = None account_references: list[str] | None = None @@ -190,46 +316,296 @@ class AppCallParams(CommonTxnParams, SenderParam): @dataclass(frozen=True) -class _RequiredMethodCallParams(SenderParam): +class _RequiredAppCreateParams: + approval_program: str | bytes + clear_state_program: str | bytes + + +@dataclass(frozen=True) +class AppCreateParams(CommonTxnParams, SenderParam, _RequiredAppCreateParams): + """ + Application create parameters. + + :param approval_program: The program to execute for all OnCompletes other than ClearState as raw teal (string) + or compiled teal (bytes) + :param clear_state_program: The program to execute for ClearState OnComplete as raw teal (string) + or compiled teal (bytes) + :param schema: The state schema for the app. This is immutable. + :param on_complete: The OnComplete action (cannot be ClearState) + :param args: Application arguments + :param account_references: Account references + :param app_references: App references + :param asset_references: Asset references + :param box_references: Box references + :param extra_program_pages: Number of extra pages required for the programs + """ + + schema: dict[str, int] | None = None + on_complete: OnComplete | None = None + args: list[bytes] | None = None + account_references: list[str] | None = None + app_references: list[int] | None = None + asset_references: list[int] | None = None + box_references: list[BoxReference] | None = None + extra_program_pages: int | None = None + + +@dataclass(frozen=True) +class _RequiredAppUpdateParams: + app_id: int + approval_program: str | bytes + clear_state_program: str | bytes + + +@dataclass(frozen=True) +class AppUpdateParams(CommonTxnParams, SenderParam, _RequiredAppUpdateParams): + """ + Application update parameters. + + :param app_id: ID of the application + :param approval_program: The program to execute for all OnCompletes other than ClearState as raw teal (string) or + compiled teal (bytes) + :param clear_state_program: The program to execute for ClearState OnComplete as raw teal (string) or compiled + teal (bytes) + """ + + args: list[bytes] | None = None + account_references: list[str] | None = None + app_references: list[int] | None = None + asset_references: list[int] | None = None + box_references: list[BoxReference] | None = None + on_complete: OnComplete | None = None + + +@dataclass(frozen=True) +class _RequiredAppDeleteParams: + app_id: int + + +@dataclass(frozen=True) +class AppDeleteParams( + CommonTxnParams, + SenderParam, + _RequiredAppDeleteParams, +): + """ + Application delete parameters. + + :param app_id: ID of the application + """ + + app_id: int + on_complete: OnComplete = OnComplete.DeleteApplicationOC + + +@dataclass(frozen=True) +class _RequiredMethodCallParams: + app_id: int + method: Method + + +@dataclass(frozen=True) +class AppMethodCall(CommonTxnParams, SenderParam, _RequiredMethodCallParams): + """Base class for ABI method calls.""" + + args: list | None = None + account_references: list[str] | None = None + app_references: list[int] | None = None + asset_references: list[int] | None = None + box_references: list[BoxReference] | None = None + + +@dataclass(frozen=True) +class _RequiredAppMethodCallParams: app_id: int method: Method @dataclass(frozen=True) -class MethodCallParams(CommonTxnParams, _RequiredMethodCallParams): +class AppMethodCallParams(CommonTxnParams, SenderParam, _RequiredAppMethodCallParams): """ Method call parameters. - :param app_id: ID of the application. - :param method: The ABI method to call. - :param args: Arguments to the ABI method. + :param app_id: ID of the application + :param method: The ABI method to call + :param args: Arguments to the ABI method + :param on_complete: The OnComplete action (cannot be UpdateApplication or ClearState) """ - args: list | None = None + args: list[bytes] | None = None + on_complete: OnComplete | None = None + account_references: list[str] | None = None + app_references: list[int] | None = None + asset_references: list[int] | None = None + box_references: list[BoxReference] | None = None + + +@dataclass(frozen=True) +class AppCallMethodCall(AppMethodCall): + """Parameters for a regular ABI method call. + + :param app_id: ID of the application + :param method: The ABI method to call + :param args: Arguments to the ABI method, either: + * An ABI value + * A transaction with explicit signer + * A transaction (where the signer will be automatically assigned) + * Another method call + * None (represents a placeholder transaction argument) + :param on_complete: The OnComplete action (cannot be UpdateApplication or ClearState) + """ + + app_id: int + on_complete: OnComplete | None = None + + +@dataclass(frozen=True) +class _RequiredAppCreateMethodCallParams: + approval_program: str | bytes + clear_state_program: str | bytes + + +@dataclass(frozen=True) +class AppCreateMethodCall(AppMethodCall, _RequiredAppCreateMethodCallParams): + """Parameters for an ABI method call that creates an application. + + :param approval_program: The program to execute for all OnCompletes other than ClearState + :param clear_state_program: The program to execute for ClearState OnComplete + :param schema: The state schema for the app + :param on_complete: The OnComplete action (cannot be ClearState) + :param extra_program_pages: Number of extra pages required for the programs + """ + + schema: dict[str, int] | None = None + on_complete: OnComplete | None = None + extra_program_pages: int | None = None + + +@dataclass(frozen=True) +class _RequiredAppUpdateMethodCallParams: + app_id: int + approval_program: str | bytes + clear_state_program: str | bytes + + +@dataclass(frozen=True) +class AppUpdateMethodCall(AppMethodCall, _RequiredAppUpdateMethodCallParams): + """Parameters for an ABI method call that updates an application. + + :param app_id: ID of the application + :param approval_program: The program to execute for all OnCompletes other than ClearState + :param clear_state_program: The program to execute for ClearState OnComplete + """ + + on_complete: OnComplete = OnComplete.UpdateApplicationOC + + +@dataclass(frozen=True) +class AppDeleteMethodCall(AppMethodCall): + """Parameters for an ABI method call that deletes an application. + + :param app_id: ID of the application + """ + + app_id: int + on_complete: OnComplete = OnComplete.DeleteApplicationOC + + +# Type alias for all possible method call types +MethodCallParams = AppCallMethodCall | AppCreateMethodCall | AppUpdateMethodCall | AppDeleteMethodCall + + +# Type alias for transaction arguments in method calls +AppMethodCallTransactionArgument = ( + TransactionWithSigner + | algosdk.transaction.Transaction + | AppCreateMethodCall + | AppUpdateMethodCall + | AppCallMethodCall +) TxnParams = Union[ # noqa: UP007 - PayParams, + PaymentParams, AssetCreateParams, AssetConfigParams, AssetFreezeParams, AssetDestroyParams, - OnlineKeyRegParams, + OnlineKeyRegistrationParams, AssetTransferParams, AssetOptInParams, + AssetOptOutParams, AppCallParams, + AppCreateParams, + AppUpdateParams, + AppDeleteParams, MethodCallParams, ] +@dataclass +class BuiltTransactions: + """ + Set of transactions built by TransactionComposer. + + :param transactions: The built transactions. + :param method_calls: Any ABIMethod objects associated with any of the transactions in a map keyed by txn id. + :param signers: Any TransactionSigner objects associated with any of the transactions in a map keyed by txn id. + """ + + transactions: list[algosdk.transaction.Transaction] + method_calls: dict[int, Method] + signers: dict[int, TransactionSigner] + + +@dataclass +class TransactionComposerBuildResult: + atc: AtomicTransactionComposer + transactions: list[TransactionWithSigner] + method_calls: dict[int, Method] + + class TransactionComposer: - def __init__( + """ + A class for composing and managing Algorand transactions using the Algosdk library. + + Attributes: + txn_method_map (dict[str, algosdk.abi.Method]): A dictionary that maps transaction IDs to their + corresponding ABI methods. + txns (List[Union[TransactionWithSigner, TxnParams, AtomicTransactionComposer]]): A list of transactions + that have not yet been composed. + atc (AtomicTransactionComposer): An instance of AtomicTransactionComposer used to compose transactions. + algod (AlgodClient): The AlgodClient instance used by the composer for suggested params. + get_suggested_params (Callable[[], algosdk.future.transaction.SuggestedParams]): A function that returns + suggested parameters for transactions. + get_signer (Callable[[str], TransactionSigner]): A function that takes an address as input and returns a + TransactionSigner for that address. + default_validity_window (int): The default validity window for transactions. + """ + + NULL_SIGNER: TransactionSigner = algosdk.atomic_transaction_composer.EmptySigner() + + def __init__( # noqa: PLR0913 self, algod: AlgodClient, get_signer: Callable[[str], TransactionSigner], get_suggested_params: Callable[[], algosdk.transaction.SuggestedParams] | None = None, default_validity_window: int | None = None, + app_manager: AppManager | None = None, ): + """ + Initialize an instance of the TransactionComposer class. + + Args: + algod (AlgodClient): An instance of AlgodClient used to get suggested params and send transactions. + get_signer (Callable[[str], TransactionSigner]): A function that takes an address as input and + returns a TransactionSigner for that address. + get_suggested_params (Optional[Callable[[], algosdk.future.transaction.SuggestedParams]], optional): A + function that returns suggested parameters for transactions. If not provided, it defaults to using + algod.suggested_params(). Defaults to None. + default_validity_window (Optional[int], optional): The default validity window for transactions. If not + provided, it defaults to 10. Defaults to None. + """ self.txn_method_map: dict[str, algosdk.abi.Method] = {} self.txns: list[TransactionWithSigner | TxnParams | AtomicTransactionComposer] = [] self.atc: AtomicTransactionComposer = AtomicTransactionComposer() @@ -238,51 +614,195 @@ def __init__( self.get_suggested_params = get_suggested_params or self.default_get_send_params self.get_signer: Callable[[str], TransactionSigner] = get_signer self.default_validity_window: int = default_validity_window or 10 + self.app_manager = app_manager or AppManager(algod) - def add_payment(self, params: PayParams) -> "TransactionComposer": + def add_transaction( + self, transaction: algosdk.transaction.Transaction, signer: TransactionSigner | None = None + ) -> TransactionComposer: + self.txns.append(TransactionWithSigner(txn=transaction, signer=signer or self.get_signer(transaction.sender))) + return self + + def add_payment(self, params: PaymentParams) -> TransactionComposer: self.txns.append(params) return self - def add_asset_create(self, params: AssetCreateParams) -> "TransactionComposer": + def add_asset_create(self, params: AssetCreateParams) -> TransactionComposer: self.txns.append(params) return self - def add_asset_config(self, params: AssetConfigParams) -> "TransactionComposer": + def add_asset_config(self, params: AssetConfigParams) -> TransactionComposer: self.txns.append(params) return self - def add_asset_freeze(self, params: AssetFreezeParams) -> "TransactionComposer": + def add_asset_freeze(self, params: AssetFreezeParams) -> TransactionComposer: self.txns.append(params) return self - def add_asset_destroy(self, params: AssetDestroyParams) -> "TransactionComposer": + def add_asset_destroy(self, params: AssetDestroyParams) -> TransactionComposer: self.txns.append(params) return self - def add_asset_transfer(self, params: AssetTransferParams) -> "TransactionComposer": + def add_asset_transfer(self, params: AssetTransferParams) -> TransactionComposer: self.txns.append(params) return self - def add_asset_opt_in(self, params: AssetOptInParams) -> "TransactionComposer": + def add_asset_opt_in(self, params: AssetOptInParams) -> TransactionComposer: self.txns.append(params) return self - def add_app_call(self, params: AppCallParams) -> "TransactionComposer": + def add_asset_opt_out(self, params: AssetOptOutParams) -> TransactionComposer: self.txns.append(params) return self - def add_online_key_reg(self, params: OnlineKeyRegParams) -> "TransactionComposer": + def add_app_create(self, params: AppCreateParams) -> TransactionComposer: self.txns.append(params) return self - def add_atc(self, atc: AtomicTransactionComposer) -> "TransactionComposer": - self.txns.append(atc) + def add_app_update(self, params: AppUpdateParams) -> TransactionComposer: + self.txns.append(params) return self - def add_method_call(self, params: MethodCallParams) -> "TransactionComposer": + def add_app_delete(self, params: AppDeleteParams) -> TransactionComposer: self.txns.append(params) return self + def add_app_call(self, params: AppCallParams) -> TransactionComposer: + self.txns.append(params) + return self + + def add_app_create_method_call(self, params: AppCreateMethodCall) -> TransactionComposer: + self.txns.append(params) + return self + + def add_app_update_method_call(self, params: AppUpdateMethodCall) -> TransactionComposer: + self.txns.append(params) + return self + + def add_app_delete_method_call(self, params: AppDeleteMethodCall) -> TransactionComposer: + self.txns.append(params) + return self + + def add_app_call_method_call(self, params: AppCallMethodCall) -> TransactionComposer: + self.txns.append(params) + return self + + def add_online_key_registration(self, params: OnlineKeyRegistrationParams) -> TransactionComposer: + self.txns.append(params) + return self + + def add_atc(self, atc: AtomicTransactionComposer) -> TransactionComposer: + self.txns.append(atc) + return self + + def count(self) -> int: + return len(self.build_transactions().transactions) + + def build(self) -> TransactionComposerBuildResult: + if self.atc.get_status() == algosdk.atomic_transaction_composer.AtomicTransactionComposerStatus.BUILDING: + suggested_params = self.get_suggested_params() + txn_with_signers: list[TransactionWithSigner] = [] + + for txn in self.txns: + txn_with_signers.extend(self._build_txn(txn, suggested_params)) + + for ts in txn_with_signers: + self.atc.add_transaction(ts) + method = self.txn_method_map.get(ts.txn.get_txid()) + if method: + self.atc.method_dict[len(self.atc.txn_list) - 1] = method + + return TransactionComposerBuildResult( + atc=self.atc, + transactions=self.atc.build_group(), + method_calls=self.atc.method_dict, + ) + + def rebuild(self) -> TransactionComposerBuildResult: + self.atc = AtomicTransactionComposer() + return self.build() + + def build_transactions(self) -> BuiltTransactions: + suggested_params = self.get_suggested_params() + + transactions: list[algosdk.transaction.Transaction] = [] + method_calls: dict[int, Method] = {} + signers: dict[int, TransactionSigner] = {} + + idx = 0 + + for txn in self.txns: + txn_with_signers: list[TransactionWithSigner] = [] + + if isinstance(txn, MethodCallParams): + txn_with_signers.extend(self._build_method_call(txn, suggested_params)) + else: + txn_with_signers.extend(self._build_txn(txn, suggested_params)) + + for ts in txn_with_signers: + transactions.append(ts.txn) + if ts.signer and ts.signer != self.NULL_SIGNER: + signers[idx] = ts.signer + method = self.txn_method_map.get(ts.txn.get_txid()) + if method: + method_calls[idx] = method + idx += 1 + + return BuiltTransactions(transactions=transactions, method_calls=method_calls, signers=signers) + + @deprecated(reason="Use send() instead", version="3.0.0") + def execute( + self, + *, + max_rounds_to_wait: int | None = None, + ) -> algosdk.atomic_transaction_composer.AtomicTransactionResponse: + return self.send( + max_rounds_to_wait=max_rounds_to_wait, + ) + + def send( + self, + max_rounds_to_wait: int | None = None, + ) -> algosdk.atomic_transaction_composer.AtomicTransactionResponse: + group = self.build().transactions + + wait_rounds = max_rounds_to_wait + if wait_rounds is None: + last_round = max(txn.txn.last_valid_round for txn in group) + first_round = self.get_suggested_params().first + wait_rounds = last_round - first_round + 1 + + try: + return self.atc.execute(client=self.algod, wait_rounds=wait_rounds) # TODO: reimplement ATC + except algosdk.error.AlgodHTTPError as e: + raise Exception(f"Transaction failed: {e}") from e + + def simulate(self) -> algosdk.atomic_transaction_composer.SimulateAtomicTransactionResponse: + if config.debug and config.project_root and config.trace_all: + return simulate_and_persist_response( + self.atc, + config.project_root, + self.algod, + config.trace_buffer_size_mb, + ) + + return simulate_response( + self.atc, + self.algod, + ) + + @staticmethod + def arc2_note(note: Arc2TransactionNote) -> bytes: + """ + Create an encoded transaction note that follows the ARC-2 spec. + + https://github.com/algorandfoundation/ARCs/blob/main/ARCs/arc-0002.md + + :param note: The ARC-2 note to encode. + """ + + arc2_payload = f"{note['dapp_name']}:{note['format']}{note['data']}" + return arc2_payload.encode("utf-8") + def _build_atc(self, atc: AtomicTransactionComposer) -> list[TransactionWithSigner]: group = atc.build_group() @@ -291,7 +811,7 @@ def _build_atc(self, atc: AtomicTransactionComposer) -> list[TransactionWithSign method = atc.method_dict.get(len(group) - 1) if method: - self.txn_method_map[group[-1].txn.get_txid()] = method # type: ignore[no-untyped-call] + self.txn_method_map[group[-1].txn.get_txid()] = method return group @@ -304,7 +824,7 @@ def _common_txn_build_step( if params.lease: txn.lease = params.lease if params.rekey_to: - txn.rekey_to = algosdk.encoding.decode_address(params.rekey_to) # type: ignore[no-untyped-call] + txn.rekey_to = algosdk.encoding.decode_address(params.rekey_to) if params.note: txn.note = params.note @@ -320,9 +840,9 @@ def _common_txn_build_step( raise ValueError("Cannot set both static_fee and extra_fee") if params.static_fee is not None: - txn.fee = params.static_fee + txn.fee = params.static_fee.micro_algos else: - txn.fee = txn.estimate_size() * suggested_params.fee or algosdk.constants.min_txn_fee # type: ignore[no-untyped-call] + txn.fee = txn.estimate_size() * suggested_params.fee or algosdk.constants.min_txn_fee if params.extra_fee: txn.fee += params.extra_fee @@ -331,23 +851,95 @@ def _common_txn_build_step( return txn + def _build_method_call( # noqa: C901, PLR0912 + self, params: MethodCallParams, suggested_params: algosdk.transaction.SuggestedParams + ) -> list[TransactionWithSigner]: + method_args: list[ABIValue | TransactionWithSigner] = [] + arg_offset = 0 + + if params.args: + for i, arg in enumerate(params.args): + if self._is_abi_value(arg): + method_args.append(arg) + continue + + if algosdk.abi.is_abi_transaction_type(params.method.args[i + arg_offset].type): + match arg: + case ( + AppCreateMethodCall() + | AppCallMethodCall() + | AppUpdateMethodCall() + | AppDeleteMethodCall() + ): + temp_txn_with_signers = self._build_method_call(arg, suggested_params) + method_args.extend(temp_txn_with_signers) + arg_offset += len(temp_txn_with_signers) - 1 + continue + case AppCallParams(): + txn = self._build_app_call(arg, suggested_params) + case PaymentParams(): + txn = self._build_payment(arg, suggested_params) + case AssetOptInParams(): + txn = self._build_asset_transfer( + AssetTransferParams(**arg.__dict__, receiver=arg.sender, amount=0), suggested_params + ) + case AssetCreateParams(): + txn = self._build_asset_create(arg, suggested_params) + case AssetConfigParams(): + txn = self._build_asset_config(arg, suggested_params) + case AssetDestroyParams(): + txn = self._build_asset_destroy(arg, suggested_params) + case AssetFreezeParams(): + txn = self._build_asset_freeze(arg, suggested_params) + case AssetTransferParams(): + txn = self._build_asset_transfer(arg, suggested_params) + case OnlineKeyRegistrationParams(): + txn = self._build_key_reg(arg, suggested_params) + case _: + raise ValueError(f"Unsupported method arg transaction type: {arg!s}") + + method_args.append( + TransactionWithSigner(txn=txn, signer=params.signer or self.get_signer(params.sender)) + ) + + continue + + raise ValueError(f"Unsupported method arg: {arg!s}") + + method_atc = AtomicTransactionComposer() + + method_atc.add_method_call( + app_id=params.app_id or 0, + method=params.method, + sender=params.sender, + sp=suggested_params, + signer=params.signer or self.get_signer(params.sender), + method_args=method_args, + on_complete=algosdk.transaction.OnComplete.NoOpOC, + note=params.note, + lease=params.lease, + boxes=[(ref.app_index, ref.name) for ref in params.box_references] if params.box_references else None, + ) + + return self._build_atc(method_atc) + def _build_payment( - self, params: PayParams, suggested_params: algosdk.transaction.SuggestedParams + self, params: PaymentParams, suggested_params: algosdk.transaction.SuggestedParams ) -> algosdk.transaction.Transaction: txn = algosdk.transaction.PaymentTxn( sender=params.sender, sp=suggested_params, receiver=params.receiver, - amt=params.amount, + amt=params.amount.micro_algos, close_remainder_to=params.close_remainder_to, - ) # type: ignore[no-untyped-call] + ) return self._common_txn_build_step(params, txn, suggested_params) def _build_asset_create( self, params: AssetCreateParams, suggested_params: algosdk.transaction.SuggestedParams ) -> algosdk.transaction.Transaction: - txn = algosdk.transaction.AssetConfigTxn( + txn = algosdk.transaction.AssetCreateTxn( sender=params.sender, sp=suggested_params, total=params.total, @@ -361,46 +953,71 @@ def _build_asset_create( url=params.url, metadata_hash=params.metadata_hash, decimals=params.decimals or 0, - strict_empty_address_check=False, - ) # type: ignore[no-untyped-call] + ) return self._common_txn_build_step(params, txn, suggested_params) def _build_app_call( - self, params: AppCallParams, suggested_params: algosdk.transaction.SuggestedParams + self, + params: AppCallParams | AppUpdateParams | AppCreateParams, + suggested_params: algosdk.transaction.SuggestedParams, ) -> algosdk.transaction.Transaction: + app_id = params.app_id if isinstance(params, AppCallParams | AppUpdateMethodCall) else None + + approval_program = None + clear_program = None + + if isinstance(params, AppUpdateParams | AppCreateParams): + if isinstance(params.approval_program, str): + approval_program = self.app_manager.compile_teal(params.approval_program).compiled_base64_to_bytes + elif isinstance(params.approval_program, bytes): + approval_program = params.approval_program + + if isinstance(params.clear_state_program, str): + clear_program = self.app_manager.compile_teal(params.clear_state_program).compiled_base64_to_bytes + elif isinstance(params.clear_state_program, bytes): + clear_program = params.clear_state_program + + approval_program_len = len(approval_program) if approval_program else 0 + clear_program_len = len(clear_program) if clear_program else 0 + sdk_params = { "sender": params.sender, "sp": suggested_params, - "index": params.app_id or 0, - "on_complete": params.on_complete or algosdk.transaction.OnComplete.NoOpOC, - "approval_program": params.approval_program, - "clear_program": params.clear_program, "app_args": params.args, + "on_complete": params.on_complete or algosdk.transaction.OnComplete.NoOpOC, "accounts": params.account_references, "foreign_apps": params.app_references, "foreign_assets": params.asset_references, - "extra_pages": params.extra_pages, - "local_schema": algosdk.transaction.StateSchema( - num_uints=params.schema.get("local_uints", 0), num_byte_slices=params.schema.get("local_byte_slices", 0) - ) # type: ignore[no-untyped-call] - if params.schema - else None, - "global_schema": algosdk.transaction.StateSchema( - num_uints=params.schema.get("global_uints", 0), - num_byte_slices=params.schema.get("global_byte_slices", 0), - ) # type: ignore[no-untyped-call] - if params.schema - else None, + "boxes": params.box_references, + "approval_program": approval_program, + "clear_program": clear_program, } - if not params.app_id: - if params.approval_program is None or params.clear_program is None: + if not app_id and isinstance(params, AppCreateParams): + if not sdk_params["approval_program"] or not sdk_params["clear_program"]: raise ValueError("approval_program and clear_program are required for application creation") - txn = algosdk.transaction.ApplicationCreateTxn(**sdk_params) # type: ignore[no-untyped-call] + if not params.schema: + raise ValueError("schema is required for application creation") + + txn = algosdk.transaction.ApplicationCreateTxn( + **sdk_params, + global_schema=algosdk.transaction.StateSchema( + num_uints=params.schema.get("global_uints", 0), + num_byte_slices=params.schema.get("global_byte_slices", 0), + ), + local_schema=algosdk.transaction.StateSchema( + num_uints=params.schema.get("local_uints", 0), + num_byte_slices=params.schema.get("local_byte_slices", 0), + ), + extra_pages=params.extra_program_pages + or math.floor((approval_program_len + clear_program_len) / algosdk.constants.APP_PAGE_MAX_SIZE) + if params.extra_program_pages + else 0, + ) else: - txn = algosdk.transaction.ApplicationCallTxn(**sdk_params) # type: ignore[assignment,no-untyped-call] + txn = algosdk.transaction.ApplicationCallTxn(**sdk_params, index=app_id) # type: ignore[assignment] return self._common_txn_build_step(params, txn, suggested_params) @@ -416,7 +1033,7 @@ def _build_asset_config( freeze=params.freeze, clawback=params.clawback, strict_empty_address_check=False, - ) # type: ignore[no-untyped-call] + ) return self._common_txn_build_step(params, txn, suggested_params) @@ -427,7 +1044,7 @@ def _build_asset_destroy( sender=params.sender, sp=suggested_params, index=params.asset_id, - ) # type: ignore[no-untyped-call] + ) return self._common_txn_build_step(params, txn, suggested_params) @@ -440,7 +1057,7 @@ def _build_asset_freeze( index=params.asset_id, target=params.account, new_freeze_state=params.frozen, - ) # type: ignore[no-untyped-call] + ) return self._common_txn_build_step(params, txn, suggested_params) @@ -455,12 +1072,12 @@ def _build_asset_transfer( index=params.asset_id, close_assets_to=params.close_asset_to, revocation_target=params.clawback_target, - ) # type: ignore[no-untyped-call] + ) return self._common_txn_build_step(params, txn, suggested_params) def _build_key_reg( - self, params: OnlineKeyRegParams, suggested_params: algosdk.transaction.SuggestedParams + self, params: OnlineKeyRegistrationParams, suggested_params: algosdk.transaction.SuggestedParams ) -> algosdk.transaction.Transaction: txn = algosdk.transaction.KeyregTxn( sender=params.sender, @@ -473,7 +1090,7 @@ def _build_key_reg( rekey_to=params.rekey_to, nonpart=False, sprfkey=params.state_proof_key, - ) # type: ignore[no-untyped-call] + ) return self._common_txn_build_step(params, txn, suggested_params) @@ -483,72 +1100,6 @@ def _is_abi_value(self, x: bool | float | str | bytes | list | TxnParams) -> boo return isinstance(x, bool | int | float | str | bytes) - def _build_method_call( # noqa: C901, PLR0912 - self, params: MethodCallParams, suggested_params: algosdk.transaction.SuggestedParams - ) -> list[TransactionWithSigner]: - method_args = [] - arg_offset = 0 - - if params.args: - for i, arg in enumerate(params.args): - if self._is_abi_value(arg): - method_args.append(arg) - continue - - if algosdk.abi.is_abi_transaction_type(params.method.args[i + arg_offset].type): - match arg: - case MethodCallParams(): - temp_txn_with_signers = self._build_method_call(arg, suggested_params) - method_args.extend(temp_txn_with_signers) - arg_offset += len(temp_txn_with_signers) - 1 - continue - case AppCallParams(): - txn = self._build_app_call(arg, suggested_params) - case PayParams(): - txn = self._build_payment(arg, suggested_params) - case AssetOptInParams(): - txn = self._build_asset_transfer( - AssetTransferParams(**arg.__dict__, receiver=arg.sender, amount=0), suggested_params - ) - case AssetCreateParams(): - txn = self._build_asset_create(arg, suggested_params) - case AssetConfigParams(): - txn = self._build_asset_config(arg, suggested_params) - case AssetDestroyParams(): - txn = self._build_asset_destroy(arg, suggested_params) - case AssetFreezeParams(): - txn = self._build_asset_freeze(arg, suggested_params) - case AssetTransferParams(): - txn = self._build_asset_transfer(arg, suggested_params) - case OnlineKeyRegParams(): - txn = self._build_key_reg(arg, suggested_params) - case _: - raise ValueError(f"Unsupported method arg transaction type: {arg}") - - method_args.append( - TransactionWithSigner(txn=txn, signer=params.signer or self.get_signer(params.sender)) - ) - - continue - - raise ValueError(f"Unsupported method arg: {arg}") - - method_atc = AtomicTransactionComposer() - - method_atc.add_method_call( - app_id=params.app_id or 0, - method=params.method, - sender=params.sender, - sp=suggested_params, - signer=params.signer or self.get_signer(params.sender), - method_args=method_args, - on_complete=algosdk.transaction.OnComplete.NoOpOC, - note=params.note, - lease=params.lease, - ) - - return self._build_atc(method_atc) - def _build_txn( # noqa: C901, PLR0912, PLR0911 self, txn: TransactionWithSigner | TxnParams | AtomicTransactionComposer, @@ -559,19 +1110,19 @@ def _build_txn( # noqa: C901, PLR0912, PLR0911 return [txn] case AtomicTransactionComposer(): return self._build_atc(txn) - case MethodCallParams(): + case AppCreateMethodCall() | AppCallMethodCall() | AppUpdateMethodCall() | AppDeleteMethodCall(): return self._build_method_call(txn, suggested_params) signer = txn.signer or self.get_signer(txn.sender) match txn: - case PayParams(): + case PaymentParams(): payment = self._build_payment(txn, suggested_params) return [TransactionWithSigner(txn=payment, signer=signer)] case AssetCreateParams(): asset_create = self._build_asset_create(txn, suggested_params) return [TransactionWithSigner(txn=asset_create, signer=signer)] - case AppCallParams(): + case AppCallParams() | AppUpdateParams() | AppCreateParams(): app_call = self._build_app_call(txn, suggested_params) return [TransactionWithSigner(txn=app_call, signer=signer)] case AssetConfigParams(): @@ -591,42 +1142,8 @@ def _build_txn( # noqa: C901, PLR0912, PLR0911 AssetTransferParams(**txn.__dict__, receiver=txn.sender, amount=0), suggested_params ) return [TransactionWithSigner(txn=asset_transfer, signer=signer)] - case OnlineKeyRegParams(): + case OnlineKeyRegistrationParams(): key_reg = self._build_key_reg(txn, suggested_params) return [TransactionWithSigner(txn=key_reg, signer=signer)] case _: raise ValueError(f"Unsupported txn: {txn}") - - def build_group(self) -> list[TransactionWithSigner]: - suggested_params = self.get_suggested_params() - - txn_with_signers: list[TransactionWithSigner] = [] - - for txn in self.txns: - txn_with_signers.extend(self._build_txn(txn, suggested_params)) - - for ts in txn_with_signers: - self.atc.add_transaction(ts) - - method_calls = {} - - for i, ts in enumerate(txn_with_signers): - method = self.txn_method_map.get(ts.txn.get_txid()) # type: ignore[no-untyped-call] - if method: - method_calls[i] = method - - self.atc.method_dict = method_calls - - return self.atc.build_group() - - def execute(self, *, max_rounds_to_wait: int | None = None) -> AtomicTransactionResponse: - group = self.build_group() - - wait_rounds = max_rounds_to_wait - - if wait_rounds is None: - last_round = max(txn.txn.last_valid_round for txn in group) - first_round = self.get_suggested_params().first - wait_rounds = last_round - first_round - - return self.atc.execute(client=self.algod, wait_rounds=wait_rounds) diff --git a/src/algokit_utils/transactions/transaction_creator.py b/src/algokit_utils/transactions/transaction_creator.py new file mode 100644 index 00000000..e4ae0e03 --- /dev/null +++ b/src/algokit_utils/transactions/transaction_creator.py @@ -0,0 +1,2 @@ +class AlgorandClientTransactionCreator: + """A creator for Algorand transactions""" diff --git a/src/algokit_utils/transactions/transaction_sender.py b/src/algokit_utils/transactions/transaction_sender.py new file mode 100644 index 00000000..41c5aa4b --- /dev/null +++ b/src/algokit_utils/transactions/transaction_sender.py @@ -0,0 +1,88 @@ +import logging +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, Generic, TypeVar + +from algosdk.transaction import wait_for_confirmation +from algosdk.v2client.algod import AlgodClient + +from algokit_utils.applications.app_manager import AppManager +from algokit_utils.assets.asset_manager import AssetManager +from algokit_utils.transactions.transaction_composer import ( + PaymentParams, + TransactionComposer, +) + +logger = logging.getLogger(__name__) +TxnParam = TypeVar("TxnParam") +TxnResult = TypeVar("TxnResult") + + +@dataclass +class SendTransactionResult(Generic[TxnResult]): + """Result of sending a transaction""" + + confirmation: dict[str, Any] + tx_id: str + return_value: TxnResult | None = None + + +class AlgorandClientTransactionSender: + """Orchestrates sending transactions for AlgorandClient.""" + + def __init__( + self, + new_group: Callable[[], TransactionComposer], + asset_manager: AssetManager, + app_manager: AppManager, + algod_client: AlgodClient, + ) -> None: + self._new_group = new_group + self._asset_manager = asset_manager + self._app_manager = app_manager + self._algod_client = algod_client + + def new_group(self) -> TransactionComposer: + """Create a new transaction group""" + return self._new_group() + + def _send( + self, + c: Callable[[TransactionComposer], Callable[[TxnParam], TransactionComposer]], + log: dict[str, Callable[[TxnParam, Any], str]] | None = None, + ) -> Callable[[TxnParam], SendTransactionResult[Any]]: + """Generic method to send transactions with logging.""" + + def send_transaction(params: TxnParam) -> SendTransactionResult[Any]: + composer = self._new_group() + c(composer)(params) + + if log and log.get("pre_log"): + transaction = composer.build().transactions[-1].txn + logger.debug(log["pre_log"](params, transaction)) + + result = composer.send() + + if log and log.get("post_log"): + logger.debug(log["post_log"](params, result)) + + confirmation = wait_for_confirmation(self._algod_client, result.tx_ids[0]) + return SendTransactionResult( + confirmation=confirmation, + tx_id=result.tx_ids[0], + ) + + return send_transaction + + @property + def payment(self) -> Callable[[PaymentParams], SendTransactionResult[None]]: + """Send a payment transaction""" + return self._send( + lambda c: c.add_payment, + { + "pre_log": lambda params, txn: ( + f"Sending {params.amount.micro_algos} µALGO from {params.sender} " + f"to {params.receiver} via transaction {txn.get_txid()}" + ) + }, + ) diff --git a/src/algokit_utils/accounts/models.py b/tests/applications/__init__.py similarity index 100% rename from src/algokit_utils/accounts/models.py rename to tests/applications/__init__.py diff --git a/tests/applications/snapshots/test_app_manager.approvals/test_comment_stripping.approved.txt b/tests/applications/snapshots/test_app_manager.approvals/test_comment_stripping.approved.txt new file mode 100644 index 00000000..3795ccbf --- /dev/null +++ b/tests/applications/snapshots/test_app_manager.approvals/test_comment_stripping.approved.txt @@ -0,0 +1,30 @@ + + +op arg +op "arg" +op "//" +op " //comment " +op "\" //" +op "// \" //" +op "" + +op 123 +op 123 +op "" +op "//" +op "//" +pushbytes base64(//8=) +pushbytes b64(//8=) + +pushbytes base64(//8=) +pushbytes b64(//8=) +pushbytes "base64(//8=)" +pushbytes "b64(//8=)" + +pushbytes base64 //8= +pushbytes b64 //8= + +pushbytes base64 //8= +pushbytes b64 //8= +pushbytes "base64 //8=" +pushbytes "b64 //8=" diff --git a/tests/applications/snapshots/test_app_manager.approvals/test_template_substitution.approved.txt b/tests/applications/snapshots/test_app_manager.approvals/test_template_substitution.approved.txt new file mode 100644 index 00000000..6cbde085 --- /dev/null +++ b/tests/applications/snapshots/test_app_manager.approvals/test_template_substitution.approved.txt @@ -0,0 +1,21 @@ + +test 123 // TMPL_INT +test 123 +no change +test 0x414243 // TMPL_STR +0x414243 +0x414243 // TMPL_INT +0x414243 // foo // +0x414243 // bar +test "TMPL_STR" // not replaced +test "TMPL_STRING" // not replaced +test TMPL_STRING // not replaced +test TMPL_STRI // not replaced +test 0x414243 123 123 0x414243 // TMPL_STR TMPL_INT TMPL_INT TMPL_STR +test 123 0x414243 TMPL_STRING "TMPL_INT TMPL_STR TMPL_STRING" //TMPL_INT TMPL_STR TMPL_STRING +test 123 123 TMPL_STRING TMPL_STRING TMPL_STRING 123 TMPL_STRING //keep +0x414243 0x414243 0x414243 +TMPL_STRING +test NOTTMPL_STR // not replaced +NOTTMPL_STR // not replaced +0x414243 // replaced \ No newline at end of file diff --git a/tests/applications/test_app_manager.py b/tests/applications/test_app_manager.py new file mode 100644 index 00000000..8c9c1002 --- /dev/null +++ b/tests/applications/test_app_manager.py @@ -0,0 +1,77 @@ +import pytest +from algokit_utils.applications.app_manager import AppManager +from algokit_utils.clients.algorand_client import AlgorandClient +from algokit_utils.models.account import Account + +from tests.conftest import check_output_stability + + +@pytest.fixture() +def algorand(funded_account: Account) -> AlgorandClient: + client = AlgorandClient.default_local_net() + client.set_signer(sender=funded_account.address, signer=funded_account.signer) + return client + + +def test_template_substitution() -> None: + program = """ +test TMPL_INT // TMPL_INT +test TMPL_INT +no change +test TMPL_STR // TMPL_STR +TMPL_STR +TMPL_STR // TMPL_INT +TMPL_STR // foo // +TMPL_STR // bar +test "TMPL_STR" // not replaced +test "TMPL_STRING" // not replaced +test TMPL_STRING // not replaced +test TMPL_STRI // not replaced +test TMPL_STR TMPL_INT TMPL_INT TMPL_STR // TMPL_STR TMPL_INT TMPL_INT TMPL_STR +test TMPL_INT TMPL_STR TMPL_STRING "TMPL_INT TMPL_STR TMPL_STRING" //TMPL_INT TMPL_STR TMPL_STRING +test TMPL_INT TMPL_INT TMPL_STRING TMPL_STRING TMPL_STRING TMPL_INT TMPL_STRING //keep +TMPL_STR TMPL_STR TMPL_STR +TMPL_STRING +test NOTTMPL_STR // not replaced +NOTTMPL_STR // not replaced +TMPL_STR // replaced +""" + result = AppManager.replace_template_variables(program, {"INT": 123, "STR": "ABC"}) + check_output_stability(result) + + +def test_comment_stripping() -> None: + program = r""" +//comment +op arg //comment +op "arg" //comment +op "//" //comment +op " //comment " //comment +op "\" //" //comment +op "// \" //" //comment +op "" //comment +// +op 123 +op 123 // something +op "" // more comments +op "//" //op "//" +op "//" +pushbytes base64(//8=) +pushbytes b64(//8=) + +pushbytes base64(//8=) // pushbytes base64(//8=) +pushbytes b64(//8=) // pushbytes b64(//8=) +pushbytes "base64(//8=)" // pushbytes "base64(//8=)" +pushbytes "b64(//8=)" // pushbytes "b64(//8=)" + +pushbytes base64 //8= +pushbytes b64 //8= + +pushbytes base64 //8= // pushbytes base64 //8= +pushbytes b64 //8= // pushbytes b64 //8= +pushbytes "base64 //8=" // pushbytes "base64 //8=" +pushbytes "b64 //8=" // pushbytes "b64 //8=" + +""" + result = AppManager.strip_teal_comments(program) + check_output_stability(result) diff --git a/src/algokit_utils/applications/models.py b/tests/clients/__init__.py similarity index 100% rename from src/algokit_utils/applications/models.py rename to tests/clients/__init__.py diff --git a/tests/clients/test_algorand_client.py b/tests/clients/test_algorand_client.py new file mode 100644 index 00000000..ce0f90d5 --- /dev/null +++ b/tests/clients/test_algorand_client.py @@ -0,0 +1,223 @@ +# TODO: Update tests for latest version of algokit-utils +# import json +# from pathlib import Path + +# import pytest +# from algokit_utils import Account, ApplicationClient +# from algokit_utils.accounts.account_manager import AddressAndSigner +# from algokit_utils.clients.algorand_client import ( +# AlgorandClient, +# AppMethodCallParams, +# AssetCreateParams, +# AssetOptInParams, +# PaymentParams, +# ) +# from algosdk.abi import Contract +# from algosdk.atomic_transaction_composer import AtomicTransactionComposer + + +# @pytest.fixture() +# def algorand(funded_account: Account) -> AlgorandClient: +# client = AlgorandClient.default_local_net() +# client.set_signer(sender=funded_account.address, signer=funded_account.signer) +# return client + + +# @pytest.fixture() +# def alice(algorand: AlgorandClient, funded_account: Account) -> AddressAndSigner: +# acct = algorand.account.random() +# algorand.send.payment(PaymentParams(sender=funded_account.address, receiver=acct.address, amount=1_000_000)) +# return acct + + +# @pytest.fixture() +# def bob(algorand: AlgorandClient, funded_account: Account) -> AddressAndSigner: +# acct = algorand.account.random() +# algorand.send.payment(PaymentParams(sender=funded_account.address, receiver=acct.address, amount=1_000_000)) +# return acct + + +# @pytest.fixture() +# def app_client(algorand: AlgorandClient, alice: AddressAndSigner) -> ApplicationClient: +# client = ApplicationClient( +# algorand.client.algod, +# Path(__file__).parent / "app_algorand_client.json", +# sender=alice.address, +# signer=alice.signer, +# ) +# client.create(call_abi_method="createApplication") +# return client + + +# @pytest.fixture() +# def contract() -> Contract: +# with Path.open(Path(__file__).parent / "app_algorand_client.json") as f: +# return Contract.from_json(json.dumps(json.load(f)["contract"])) + + +# def test_send_payment(algorand: AlgorandClient, alice: AddressAndSigner, bob: AddressAndSigner) -> None: +# amount = 100_000 + +# alice_pre_balance = algorand.account.get_information(alice.address)["amount"] +# bob_pre_balance = algorand.account.get_information(bob.address)["amount"] +# result = algorand.send.payment(PaymentParams(sender=alice.address, receiver=bob.address, amount=amount)) +# alice_post_balance = algorand.account.get_information(alice.address)["amount"] +# bob_post_balance = algorand.account.get_information(bob.address)["amount"] + +# assert result["confirmation"] is not None +# assert alice_post_balance == alice_pre_balance - 1000 - amount +# assert bob_post_balance == bob_pre_balance + amount + + +# def test_send_asset_create(algorand: AlgorandClient, alice: AddressAndSigner) -> None: +# total = 100 + +# result = algorand.send.asset_create(AssetCreateParams(sender=alice.address, total=total)) +# asset_index = result["confirmation"]["asset-index"] + +# assert asset_index > 0 + + +# def test_asset_opt_in(algorand: AlgorandClient, alice: AddressAndSigner, bob: AddressAndSigner) -> None: +# total = 100 + +# result = algorand.send.asset_create(AssetCreateParams(sender=alice.address, total=total)) +# asset_index = result["confirmation"]["asset-index"] + +# algorand.send.asset_opt_in(AssetOptInParams(sender=bob.address, asset_id=asset_index)) + +# assert algorand.account.get_asset_information(bob.address, asset_index) is not None + + +# DO_MATH_VALUE = 3 + + +# def test_add_atc(algorand: AlgorandClient, app_client: ApplicationClient, alice: AddressAndSigner) -> None: +# atc = AtomicTransactionComposer() +# app_client.compose_call(atc, call_abi_method="doMath", a=1, b=2, operation="sum") + +# result = ( +# algorand.new_group() +# .add_payment(PaymentParams(sender=alice.address, amount=0, receiver=alice.address)) +# .add_atc(atc) +# .execute() +# ) +# assert result.abi_results[0].return_value == DO_MATH_VALUE + + +# def test_add_method_call( +# algorand: AlgorandClient, contract: Contract, alice: AddressAndSigner, app_client: ApplicationClient +# ) -> None: +# result = ( +# algorand.new_group() +# .add_payment(PaymentParams(sender=alice.address, amount=0, receiver=alice.address)) +# .add_method_call( +# AppMethodCallParams( +# method=contract.get_method_by_name("doMath"), +# sender=alice.address, +# app_id=app_client.app_id, +# args=[1, 2, "sum"], +# ) +# ) +# .execute() +# ) +# assert result.abi_results[0].return_value == DO_MATH_VALUE + + +# def test_add_method_with_txn_arg( +# algorand: AlgorandClient, contract: Contract, alice: AddressAndSigner, app_client: ApplicationClient +# ) -> None: +# pay_arg = PaymentParams(sender=alice.address, receiver=alice.address, amount=1) +# result = ( +# algorand.new_group() +# .add_payment(PaymentParams(sender=alice.address, amount=0, receiver=alice.address)) +# .add_method_call( +# AppMethodCallParams( +# method=contract.get_method_by_name("txnArg"), +# sender=alice.address, +# app_id=app_client.app_id, +# args=[pay_arg], +# ) +# ) +# .execute() +# ) +# assert result.abi_results[0].return_value == alice.address + + +# def test_add_method_call_with_method_call_arg( +# algorand: AlgorandClient, contract: Contract, alice: AddressAndSigner, app_client: ApplicationClient +# ) -> None: +# hello_world_call = AppMethodCallParams( +# method=contract.get_method_by_name("helloWorld"), sender=alice.address, app_id=app_client.app_id +# ) +# result = ( +# algorand.new_group() +# .add_method_call( +# AppMethodCallParams( +# method=contract.get_method_by_name("methodArg"), +# sender=alice.address, +# app_id=app_client.app_id, +# args=[hello_world_call], +# ) +# ) +# .execute() +# ) +# assert result.abi_results[0].return_value == "Hello, World!" +# assert result.abi_results[1].return_value == app_client.app_id + + +# def test_add_method_call_with_method_call_arg_with_txn_arg( +# algorand: AlgorandClient, contract: Contract, alice: AddressAndSigner, app_client: ApplicationClient +# ) -> None: +# pay_arg = PaymentParams(sender=alice.address, receiver=alice.address, amount=1) +# txn_arg_call = AppMethodCallParams( +# method=contract.get_method_by_name("txnArg"), sender=alice.address, app_id=app_client.app_id, args=[pay_arg] +# ) +# result = ( +# algorand.new_group() +# .add_method_call( +# AppMethodCallParams( +# method=contract.get_method_by_name("nestedTxnArg"), +# sender=alice.address, +# app_id=app_client.app_id, +# args=[txn_arg_call], +# ) +# ) +# .execute() +# ) +# assert result.abi_results[0].return_value == alice.address +# assert result.abi_results[1].return_value == app_client.app_id + + +# def test_add_method_call_with_two_method_call_args_with_txn_arg( +# algorand: AlgorandClient, contract: Contract, alice: AddressAndSigner, app_client: ApplicationClient +# ) -> None: +# pay_arg_1 = PaymentParams(sender=alice.address, receiver=alice.address, amount=1) +# txn_arg_call_1 = AppMethodCallParams( +# method=contract.get_method_by_name("txnArg"), +# sender=alice.address, +# app_id=app_client.app_id, +# args=[pay_arg_1], +# note=b"1", +# ) + +# pay_arg_2 = PaymentParams(sender=alice.address, receiver=alice.address, amount=2) +# txn_arg_call_2 = AppMethodCallParams( +# method=contract.get_method_by_name("txnArg"), sender=alice.address, app_id=app_client.app_id, args=[pay_arg_2] +# ) + +# result = ( +# algorand.new_group() +# .add_method_call( +# AppMethodCallParams( +# method=contract.get_method_by_name("doubleNestedTxnArg"), +# sender=alice.address, +# app_id=app_client.app_id, +# args=[txn_arg_call_1, txn_arg_call_2], +# ) +# ) +# .execute() +# ) +# assert result.abi_results[0].return_value == alice.address +# assert result.abi_results[1].return_value == alice.address +# assert result.abi_results[2].return_value == app_client.app_id diff --git a/tests/conftest.py b/tests/conftest.py index e3997a2c..18021c21 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -45,7 +45,7 @@ def check_output_stability(logs: str, *, test_name: str | None = None) -> None: caller_dir = caller_path.parent test_name = test_name or caller_frame.function caller_stem = Path(caller_frame.filename).stem - output_dir = caller_dir / f"{caller_stem}.approvals" + output_dir = caller_dir / "snapshots" / f"{caller_stem}.approvals" output_dir.mkdir(exist_ok=True) output_file = output_dir / f"{test_name}.approved.txt" output_file_str = str(output_file) @@ -188,11 +188,11 @@ def generate_test_asset(algod_client: "AlgodClient", sender: Account, total: int note=None, lease=None, rekey_to=None, - ) # type: ignore[no-untyped-call] + ) - signed_transaction = txn.sign(sender.private_key) # type: ignore[no-untyped-call] + signed_transaction = txn.sign(sender.private_key) algod_client.send_transaction(signed_transaction) - ptx = algod_client.pending_transaction_info(txn.get_txid()) # type: ignore[no-untyped-call] + ptx = algod_client.pending_transaction_info(txn.get_txid()) if isinstance(ptx, dict) and "asset-index" in ptx and isinstance(ptx["asset-index"], int): return ptx["asset-index"] diff --git a/tests/test_algorand_client.py b/tests/test_algorand_client.py deleted file mode 100644 index 8b7c448d..00000000 --- a/tests/test_algorand_client.py +++ /dev/null @@ -1,222 +0,0 @@ -import json -from pathlib import Path - -import pytest -from algokit_utils import Account, ApplicationClient -from algokit_utils.accounts.account_manager import AddressAndSigner -from algokit_utils.clients.algorand_client import ( - AlgorandClient, - AssetCreateParams, - AssetOptInParams, - MethodCallParams, - PayParams, -) -from algosdk.abi import Contract -from algosdk.atomic_transaction_composer import AtomicTransactionComposer - - -@pytest.fixture() -def algorand(funded_account: Account) -> AlgorandClient: - client = AlgorandClient.default_local_net() - client.set_signer(sender=funded_account.address, signer=funded_account.signer) - return client - - -@pytest.fixture() -def alice(algorand: AlgorandClient, funded_account: Account) -> AddressAndSigner: - acct = algorand.account.random() - algorand.send.payment(PayParams(sender=funded_account.address, receiver=acct.address, amount=1_000_000)) - return acct - - -@pytest.fixture() -def bob(algorand: AlgorandClient, funded_account: Account) -> AddressAndSigner: - acct = algorand.account.random() - algorand.send.payment(PayParams(sender=funded_account.address, receiver=acct.address, amount=1_000_000)) - return acct - - -@pytest.fixture() -def app_client(algorand: AlgorandClient, alice: AddressAndSigner) -> ApplicationClient: - client = ApplicationClient( - algorand.client.algod, - Path(__file__).parent / "app_algorand_client.json", - sender=alice.address, - signer=alice.signer, - ) - client.create(call_abi_method="createApplication") - return client - - -@pytest.fixture() -def contract() -> Contract: - with Path.open(Path(__file__).parent / "app_algorand_client.json") as f: - return Contract.from_json(json.dumps(json.load(f)["contract"])) - - -def test_send_payment(algorand: AlgorandClient, alice: AddressAndSigner, bob: AddressAndSigner) -> None: - amount = 100_000 - - alice_pre_balance = algorand.account.get_information(alice.address)["amount"] - bob_pre_balance = algorand.account.get_information(bob.address)["amount"] - result = algorand.send.payment(PayParams(sender=alice.address, receiver=bob.address, amount=amount)) - alice_post_balance = algorand.account.get_information(alice.address)["amount"] - bob_post_balance = algorand.account.get_information(bob.address)["amount"] - - assert result["confirmation"] is not None - assert alice_post_balance == alice_pre_balance - 1000 - amount - assert bob_post_balance == bob_pre_balance + amount - - -def test_send_asset_create(algorand: AlgorandClient, alice: AddressAndSigner) -> None: - total = 100 - - result = algorand.send.asset_create(AssetCreateParams(sender=alice.address, total=total)) - asset_index = result["confirmation"]["asset-index"] - - assert asset_index > 0 - - -def test_asset_opt_in(algorand: AlgorandClient, alice: AddressAndSigner, bob: AddressAndSigner) -> None: - total = 100 - - result = algorand.send.asset_create(AssetCreateParams(sender=alice.address, total=total)) - asset_index = result["confirmation"]["asset-index"] - - algorand.send.asset_opt_in(AssetOptInParams(sender=bob.address, asset_id=asset_index)) - - assert algorand.account.get_asset_information(bob.address, asset_index) is not None - - -DO_MATH_VALUE = 3 - - -def test_add_atc(algorand: AlgorandClient, app_client: ApplicationClient, alice: AddressAndSigner) -> None: - atc = AtomicTransactionComposer() - app_client.compose_call(atc, call_abi_method="doMath", a=1, b=2, operation="sum") - - result = ( - algorand.new_group() - .add_payment(PayParams(sender=alice.address, amount=0, receiver=alice.address)) - .add_atc(atc) - .execute() - ) - assert result.abi_results[0].return_value == DO_MATH_VALUE - - -def test_add_method_call( - algorand: AlgorandClient, contract: Contract, alice: AddressAndSigner, app_client: ApplicationClient -) -> None: - result = ( - algorand.new_group() - .add_payment(PayParams(sender=alice.address, amount=0, receiver=alice.address)) - .add_method_call( - MethodCallParams( - method=contract.get_method_by_name("doMath"), - sender=alice.address, - app_id=app_client.app_id, - args=[1, 2, "sum"], - ) - ) - .execute() - ) - assert result.abi_results[0].return_value == DO_MATH_VALUE - - -def test_add_method_with_txn_arg( - algorand: AlgorandClient, contract: Contract, alice: AddressAndSigner, app_client: ApplicationClient -) -> None: - pay_arg = PayParams(sender=alice.address, receiver=alice.address, amount=1) - result = ( - algorand.new_group() - .add_payment(PayParams(sender=alice.address, amount=0, receiver=alice.address)) - .add_method_call( - MethodCallParams( - method=contract.get_method_by_name("txnArg"), - sender=alice.address, - app_id=app_client.app_id, - args=[pay_arg], - ) - ) - .execute() - ) - assert result.abi_results[0].return_value == alice.address - - -def test_add_method_call_with_method_call_arg( - algorand: AlgorandClient, contract: Contract, alice: AddressAndSigner, app_client: ApplicationClient -) -> None: - hello_world_call = MethodCallParams( - method=contract.get_method_by_name("helloWorld"), sender=alice.address, app_id=app_client.app_id - ) - result = ( - algorand.new_group() - .add_method_call( - MethodCallParams( - method=contract.get_method_by_name("methodArg"), - sender=alice.address, - app_id=app_client.app_id, - args=[hello_world_call], - ) - ) - .execute() - ) - assert result.abi_results[0].return_value == "Hello, World!" - assert result.abi_results[1].return_value == app_client.app_id - - -def test_add_method_call_with_method_call_arg_with_txn_arg( - algorand: AlgorandClient, contract: Contract, alice: AddressAndSigner, app_client: ApplicationClient -) -> None: - pay_arg = PayParams(sender=alice.address, receiver=alice.address, amount=1) - txn_arg_call = MethodCallParams( - method=contract.get_method_by_name("txnArg"), sender=alice.address, app_id=app_client.app_id, args=[pay_arg] - ) - result = ( - algorand.new_group() - .add_method_call( - MethodCallParams( - method=contract.get_method_by_name("nestedTxnArg"), - sender=alice.address, - app_id=app_client.app_id, - args=[txn_arg_call], - ) - ) - .execute() - ) - assert result.abi_results[0].return_value == alice.address - assert result.abi_results[1].return_value == app_client.app_id - - -def test_add_method_call_with_two_method_call_args_with_txn_arg( - algorand: AlgorandClient, contract: Contract, alice: AddressAndSigner, app_client: ApplicationClient -) -> None: - pay_arg_1 = PayParams(sender=alice.address, receiver=alice.address, amount=1) - txn_arg_call_1 = MethodCallParams( - method=contract.get_method_by_name("txnArg"), - sender=alice.address, - app_id=app_client.app_id, - args=[pay_arg_1], - note=b"1", - ) - - pay_arg_2 = PayParams(sender=alice.address, receiver=alice.address, amount=2) - txn_arg_call_2 = MethodCallParams( - method=contract.get_method_by_name("txnArg"), sender=alice.address, app_id=app_client.app_id, args=[pay_arg_2] - ) - - result = ( - algorand.new_group() - .add_method_call( - MethodCallParams( - method=contract.get_method_by_name("doubleNestedTxnArg"), - sender=alice.address, - app_id=app_client.app_id, - args=[txn_arg_call_1, txn_arg_call_2], - ) - ) - .execute() - ) - assert result.abi_results[0].return_value == alice.address - assert result.abi_results[1].return_value == alice.address - assert result.abi_results[2].return_value == app_client.app_id diff --git a/tests/test_transaction_composer.py b/tests/test_transaction_composer.py new file mode 100644 index 00000000..1cbab861 --- /dev/null +++ b/tests/test_transaction_composer.py @@ -0,0 +1,212 @@ +from typing import TYPE_CHECKING + +import pytest +from algokit_utils._legacy_v2.account import get_account +from algokit_utils.clients.algorand_client import AlgorandClient +from algokit_utils.models.account import Account +from algokit_utils.models.amount import AlgoAmount +from algokit_utils.transactions.transaction_composer import ( + AppCreateParams, + AssetConfigParams, + AssetCreateParams, + PaymentParams, + TransactionComposer, +) +from algosdk.atomic_transaction_composer import ( + AtomicTransactionResponse, +) +from algosdk.transaction import ( + ApplicationCreateTxn, + AssetConfigTxn, + AssetCreateTxn, + PaymentTxn, +) + +from legacy_v2_tests.conftest import get_unique_name + +if TYPE_CHECKING: + from algokit_utils.transactions.models import Arc2TransactionNote + + +@pytest.fixture() +def algorand(funded_account: Account) -> AlgorandClient: + client = AlgorandClient.default_local_net() + client.set_signer(sender=funded_account.address, signer=funded_account.signer) + return client + + +@pytest.fixture() +def funded_secondary_account(algorand: AlgorandClient) -> Account: + secondary_name = get_unique_name() + return get_account(algorand.client.algod, secondary_name) + + +def test_add_transaction(algorand: AlgorandClient, funded_account: Account) -> None: + composer = TransactionComposer( + algod=algorand.client.algod, + get_signer=lambda _: funded_account.signer, + ) + txn = PaymentTxn( + sender=funded_account.address, + sp=algorand.client.algod.suggested_params(), + receiver=funded_account.address, + amt=AlgoAmount.from_algos(1).micro_algos, + ) + composer.add_transaction(txn) + built = composer.build_transactions() + + assert len(built.transactions) == 1 + assert isinstance(built.transactions[0], PaymentTxn) + assert built.transactions[0].sender == funded_account.address + assert built.transactions[0].receiver == funded_account.address + assert built.transactions[0].amt == AlgoAmount.from_algos(1).micro_algos + + +def test_add_asset_create(algorand: AlgorandClient, funded_account: Account) -> None: + composer = TransactionComposer( + algod=algorand.client.algod, + get_signer=lambda _: funded_account.signer, + ) + expected_total = 1000 + params = AssetCreateParams( + sender=funded_account.address, + total=expected_total, + decimals=0, + default_frozen=False, + unit_name="TEST", + asset_name="Test Asset", + url="https://example.com", + ) + + composer.add_asset_create(params) + built = composer.build_transactions() + response = composer.execute(max_rounds_to_wait=20) + created_asset = algorand.client.algod.asset_info( + algorand.client.algod.pending_transaction_info(response.tx_ids[0])["asset-index"] # type: ignore[call-overload] + )["params"] + + assert len(response.tx_ids) == 1 + assert response.confirmed_round > 0 + assert isinstance(built.transactions[0], AssetCreateTxn) + txn = built.transactions[0] + assert txn.sender == funded_account.address + assert created_asset["creator"] == funded_account.address + assert txn.total == created_asset["total"] == expected_total + assert txn.decimals == created_asset["decimals"] == 0 + assert txn.default_frozen == created_asset["default-frozen"] is False + assert txn.unit_name == created_asset["unit-name"] == "TEST" + assert txn.asset_name == created_asset["name"] == "Test Asset" + + +def test_add_asset_config(algorand: AlgorandClient, funded_account: Account, funded_secondary_account: Account) -> None: + # First create an asset + asset_txn = AssetCreateTxn( + sender=funded_account.address, + sp=algorand.client.algod.suggested_params(), + total=1000, + decimals=0, + default_frozen=False, + unit_name="CFG", + asset_name="Configurable Asset", + manager=funded_account.address, + ) + signed_asset_txn = asset_txn.sign(funded_account.signer.private_key) + tx_id = algorand.client.algod.send_transaction(signed_asset_txn) + asset_before_config = algorand.client.algod.asset_info( + algorand.client.algod.pending_transaction_info(tx_id)["asset-index"] # type: ignore[call-overload] + ) + asset_before_config_index = asset_before_config["index"] # type: ignore[call-overload] + + composer = TransactionComposer( + algod=algorand.client.algod, + get_signer=lambda _: funded_account.signer, + ) + params = AssetConfigParams( + sender=funded_account.address, + asset_id=asset_before_config_index, + manager=funded_secondary_account.address, + ) + composer.add_asset_config(params) + built = composer.build_transactions() + + assert len(built.transactions) == 1 + assert isinstance(built.transactions[0], AssetConfigTxn) + txn = built.transactions[0] + assert txn.sender == funded_account.address + assert txn.index == asset_before_config_index + assert txn.manager == funded_secondary_account.address + + composer.execute(max_rounds_to_wait=20) + updated_asset = algorand.client.algod.asset_info(asset_id=asset_before_config_index)["params"] # type: ignore[call-overload] + assert updated_asset["manager"] == funded_secondary_account.address + + +def test_add_app_create(algorand: AlgorandClient, funded_account: Account) -> None: + composer = TransactionComposer( + algod=algorand.client.algod, + get_signer=lambda _: funded_account.signer, + ) + approval_program = "#pragma version 6\nint 1" + clear_state_program = "#pragma version 6\nint 1" + params = AppCreateParams( + sender=funded_account.address, + approval_program=approval_program, + clear_state_program=clear_state_program, + schema={"global_ints": 0, "global_bytes": 0, "local_ints": 0, "local_bytes": 0}, + ) + composer.add_app_create(params) + built = composer.build_transactions() + + assert len(built.transactions) == 1 + assert isinstance(built.transactions[0], ApplicationCreateTxn) + txn = built.transactions[0] + assert txn.sender == funded_account.address + assert txn.approval_program == b"\x06\x81\x01" + assert txn.clear_program == b"\x06\x81\x01" + composer.execute(max_rounds_to_wait=20) + + +def test_simulate(algorand: AlgorandClient, funded_account: Account) -> None: + composer = TransactionComposer( + algod=algorand.client.algod, + get_signer=lambda _: funded_account.signer, + ) + composer.add_payment( + PaymentParams( + sender=funded_account.address, + receiver=funded_account.address, + amount=AlgoAmount.from_algos(1), + ) + ) + composer.build() + simulate_response = composer.simulate() + assert simulate_response + + +def test_send(algorand: AlgorandClient, funded_account: Account) -> None: + composer = TransactionComposer( + algod=algorand.client.algod, + get_signer=lambda _: funded_account.signer, + ) + composer.add_payment( + PaymentParams( + sender=funded_account.address, + receiver=funded_account.address, + amount=AlgoAmount.from_algos(1), + ) + ) + response = composer.send() + assert isinstance(response, AtomicTransactionResponse) + assert len(response.tx_ids) == 1 + assert response.confirmed_round > 0 + + +def test_arc2_note() -> None: + note_data: Arc2TransactionNote = { + "dapp_name": "TestDApp", + "format": "j", + "data": '{"key":"value"}', + } + encoded_note = TransactionComposer.arc2_note(note_data) + expected_note = b'TestDApp:j{"key":"value"}' + assert encoded_note == expected_note diff --git a/src/algokit_utils/assets/models.py b/tests/transactions/__init__.py similarity index 100% rename from src/algokit_utils/assets/models.py rename to tests/transactions/__init__.py diff --git a/tests/transactions/artifacts/hello_world/approval.teal b/tests/transactions/artifacts/hello_world/approval.teal new file mode 100644 index 00000000..d38f6432 --- /dev/null +++ b/tests/transactions/artifacts/hello_world/approval.teal @@ -0,0 +1,62 @@ +#pragma version 10 + +smart_contracts.hello_world.contract.HelloWorld.approval_program: + intcblock 0 1 + callsub __puya_arc4_router__ + return + + +// smart_contracts.hello_world.contract.HelloWorld.__puya_arc4_router__() -> uint64: +__puya_arc4_router__: + proto 0 1 + txn NumAppArgs + bz __puya_arc4_router___bare_routing@5 + pushbytes 0x02bece11 // method "hello(string)string" + txna ApplicationArgs 0 + match __puya_arc4_router___hello_route@2 + intc_0 // 0 + retsub + +__puya_arc4_router___hello_route@2: + txn OnCompletion + ! + assert // OnCompletion is NoOp + txn ApplicationID + assert // is not creating + txna ApplicationArgs 1 + extract 2 0 + callsub hello + dup + len + itob + extract 6 2 + swap + concat + pushbytes 0x151f7c75 + swap + concat + log + intc_1 // 1 + retsub + +__puya_arc4_router___bare_routing@5: + txn OnCompletion + bnz __puya_arc4_router___after_if_else@9 + txn ApplicationID + ! + assert // is creating + intc_1 // 1 + retsub + +__puya_arc4_router___after_if_else@9: + intc_0 // 0 + retsub + + +// smart_contracts.hello_world.contract.HelloWorld.hello(name: bytes) -> bytes: +hello: + proto 1 1 + pushbytes "Hello, " + frame_dig -1 + concat + retsub diff --git a/tests/transactions/artifacts/hello_world/clear.teal b/tests/transactions/artifacts/hello_world/clear.teal new file mode 100644 index 00000000..5a70c80b --- /dev/null +++ b/tests/transactions/artifacts/hello_world/clear.teal @@ -0,0 +1,5 @@ +#pragma version 10 + +smart_contracts.hello_world.contract.HelloWorld.clear_state_program: + pushint 1 // 1 + return diff --git a/tests/transactions/test_transaction_composer.py b/tests/transactions/test_transaction_composer.py new file mode 100644 index 00000000..0a75961a --- /dev/null +++ b/tests/transactions/test_transaction_composer.py @@ -0,0 +1,256 @@ +from pathlib import Path +from typing import TYPE_CHECKING + +import algosdk +import pytest +from algokit_utils._legacy_v2.account import get_account +from algokit_utils.clients.algorand_client import AlgorandClient +from algokit_utils.models.account import Account +from algokit_utils.models.amount import AlgoAmount +from algokit_utils.transactions.transaction_composer import ( + AppCallMethodCall, + AppCreateParams, + AssetConfigParams, + AssetCreateParams, + PaymentParams, + TransactionComposer, +) +from algosdk.atomic_transaction_composer import ( + AtomicTransactionResponse, +) +from algosdk.transaction import ( + ApplicationCallTxn, + ApplicationCreateTxn, + AssetConfigTxn, + AssetCreateTxn, + PaymentTxn, +) + +from legacy_v2_tests.conftest import get_unique_name + +if TYPE_CHECKING: + from algokit_utils.transactions.models import Arc2TransactionNote + + +@pytest.fixture() +def algorand(funded_account: Account) -> AlgorandClient: + client = AlgorandClient.default_local_net() + client.set_signer(sender=funded_account.address, signer=funded_account.signer) + return client + + +@pytest.fixture() +def funded_secondary_account(algorand: AlgorandClient) -> Account: + secondary_name = get_unique_name() + return get_account(algorand.client.algod, secondary_name) + + +def test_add_transaction(algorand: AlgorandClient, funded_account: Account) -> None: + composer = TransactionComposer( + algod=algorand.client.algod, + get_signer=lambda _: funded_account.signer, + ) + txn = PaymentTxn( + sender=funded_account.address, + sp=algorand.client.algod.suggested_params(), + receiver=funded_account.address, + amt=AlgoAmount.from_algos(1).micro_algos, + ) + composer.add_transaction(txn) + built = composer.build_transactions() + + assert len(built.transactions) == 1 + assert isinstance(built.transactions[0], PaymentTxn) + assert built.transactions[0].sender == funded_account.address + assert built.transactions[0].receiver == funded_account.address + assert built.transactions[0].amt == AlgoAmount.from_algos(1).micro_algos + + +def test_add_asset_create(algorand: AlgorandClient, funded_account: Account) -> None: + composer = TransactionComposer( + algod=algorand.client.algod, + get_signer=lambda _: funded_account.signer, + ) + expected_total = 1000 + params = AssetCreateParams( + sender=funded_account.address, + total=expected_total, + decimals=0, + default_frozen=False, + unit_name="TEST", + asset_name="Test Asset", + url="https://example.com", + ) + + composer.add_asset_create(params) + built = composer.build_transactions() + response = composer.execute(max_rounds_to_wait=20) + created_asset = algorand.client.algod.asset_info( + algorand.client.algod.pending_transaction_info(response.tx_ids[0])["asset-index"] # type: ignore[call-overload] + )["params"] + + assert len(response.tx_ids) == 1 + assert response.confirmed_round > 0 + assert isinstance(built.transactions[0], AssetCreateTxn) + txn = built.transactions[0] + assert txn.sender == funded_account.address + assert created_asset["creator"] == funded_account.address + assert txn.total == created_asset["total"] == expected_total + assert txn.decimals == created_asset["decimals"] == 0 + assert txn.default_frozen == created_asset["default-frozen"] is False + assert txn.unit_name == created_asset["unit-name"] == "TEST" + assert txn.asset_name == created_asset["name"] == "Test Asset" + + +def test_add_asset_config(algorand: AlgorandClient, funded_account: Account, funded_secondary_account: Account) -> None: + # First create an asset + asset_txn = AssetCreateTxn( + sender=funded_account.address, + sp=algorand.client.algod.suggested_params(), + total=1000, + decimals=0, + default_frozen=False, + unit_name="CFG", + asset_name="Configurable Asset", + manager=funded_account.address, + ) + signed_asset_txn = asset_txn.sign(funded_account.signer.private_key) + tx_id = algorand.client.algod.send_transaction(signed_asset_txn) + asset_before_config = algorand.client.algod.asset_info( + algorand.client.algod.pending_transaction_info(tx_id)["asset-index"] # type: ignore[call-overload] + ) + asset_before_config_index = asset_before_config["index"] # type: ignore[call-overload] + + composer = TransactionComposer( + algod=algorand.client.algod, + get_signer=lambda _: funded_account.signer, + ) + params = AssetConfigParams( + sender=funded_account.address, + asset_id=asset_before_config_index, + manager=funded_secondary_account.address, + ) + composer.add_asset_config(params) + built = composer.build_transactions() + + assert len(built.transactions) == 1 + assert isinstance(built.transactions[0], AssetConfigTxn) + txn = built.transactions[0] + assert txn.sender == funded_account.address + assert txn.index == asset_before_config_index + assert txn.manager == funded_secondary_account.address + + composer.execute(max_rounds_to_wait=20) + updated_asset = algorand.client.algod.asset_info(asset_id=asset_before_config_index)["params"] # type: ignore[call-overload] + assert updated_asset["manager"] == funded_secondary_account.address + + +def test_add_app_create(algorand: AlgorandClient, funded_account: Account) -> None: + composer = TransactionComposer( + algod=algorand.client.algod, + get_signer=lambda _: funded_account.signer, + ) + approval_program = "#pragma version 6\nint 1" + clear_state_program = "#pragma version 6\nint 1" + params = AppCreateParams( + sender=funded_account.address, + approval_program=approval_program, + clear_state_program=clear_state_program, + schema={"global_ints": 0, "global_bytes": 0, "local_ints": 0, "local_bytes": 0}, + ) + composer.add_app_create(params) + built = composer.build_transactions() + + assert len(built.transactions) == 1 + assert isinstance(built.transactions[0], ApplicationCreateTxn) + txn = built.transactions[0] + assert txn.sender == funded_account.address + assert txn.approval_program == b"\x06\x81\x01" + assert txn.clear_program == b"\x06\x81\x01" + composer.execute(max_rounds_to_wait=20) + + +def test_add_app_call_method_call(algorand: AlgorandClient, funded_account: Account) -> None: + composer = TransactionComposer( + algod=algorand.client.algod, + get_signer=lambda _: funded_account.signer, + ) + approval_program = Path(Path(__file__).parent / "artifacts" / "hello_world" / "approval.teal").read_text() + clear_state_program = Path(Path(__file__).parent / "artifacts" / "hello_world" / "clear.teal").read_text() + composer.add_app_create( + AppCreateParams( + sender=funded_account.address, + approval_program=approval_program, + clear_state_program=clear_state_program, + schema={"global_ints": 0, "global_bytes": 0, "local_ints": 0, "local_bytes": 0}, + ) + ) + response = composer.execute() + app_id = algorand.client.algod.pending_transaction_info(response.tx_ids[0])["application-index"] # type: ignore[call-overload] + + composer = TransactionComposer( + algod=algorand.client.algod, + get_signer=lambda _: funded_account.signer, + ) + composer.add_app_call_method_call( + AppCallMethodCall( + sender=funded_account.address, + app_id=app_id, + method=algosdk.abi.Method.from_signature("hello(string)string"), + args=["world"], + ) + ) + built = composer.build_transactions() + + assert len(built.transactions) == 1 + assert isinstance(built.transactions[0], ApplicationCallTxn) + txn = built.transactions[0] + assert txn.sender == funded_account.address + response = composer.execute(max_rounds_to_wait=20) + assert response.abi_results[0].return_value == "Hello, world" + + +def test_simulate(algorand: AlgorandClient, funded_account: Account) -> None: + composer = TransactionComposer( + algod=algorand.client.algod, + get_signer=lambda _: funded_account.signer, + ) + composer.add_payment( + PaymentParams( + sender=funded_account.address, + receiver=funded_account.address, + amount=AlgoAmount.from_algos(1), + ) + ) + composer.build() + simulate_response = composer.simulate() + assert simulate_response + + +def test_send(algorand: AlgorandClient, funded_account: Account) -> None: + composer = TransactionComposer( + algod=algorand.client.algod, + get_signer=lambda _: funded_account.signer, + ) + composer.add_payment( + PaymentParams( + sender=funded_account.address, + receiver=funded_account.address, + amount=AlgoAmount.from_algos(1), + ) + ) + response = composer.send() + assert isinstance(response, AtomicTransactionResponse) + assert len(response.tx_ids) == 1 + assert response.confirmed_round > 0 + + +def test_arc2_note() -> None: + note_data: Arc2TransactionNote = { + "dapp_name": "TestDApp", + "format": "j", + "data": '{"key":"value"}', + } + encoded_note = TransactionComposer.arc2_note(note_data) + expected_note = b'TestDApp:j{"key":"value"}' + assert encoded_note == expected_note From 6cd11be5a1fcfd0d2ba7a8c17b6c477a14e61039 Mon Sep 17 00:00:00 2001 From: Al Date: Wed, 6 Nov 2024 15:26:59 +0100 Subject: [PATCH 3/6] feat: AlgorandClientTransaction(Creator|Sender) and AssetManager abstractions (#123) * chore: wip * feat: initial implementation of TransactionSender, TransactionCreator and AssetManager --- src/algokit_utils/applications/app_manager.py | 21 + src/algokit_utils/assets/asset_manager.py | 267 +++++++++++- src/algokit_utils/clients/algorand_client.py | 86 ++-- .../transactions/transaction_composer.py | 164 +++++++- .../transactions/transaction_creator.py | 150 ++++++- .../transactions/transaction_sender.py | 349 ++++++++++++++-- tests/assets/__init__.py | 0 tests/assets/test_asset_manager.py | 207 ++++++++++ tests/test_transaction_composer.py | 10 +- .../transactions/test_transaction_composer.py | 12 +- .../transactions/test_transaction_creator.py | 267 ++++++++++++ tests/transactions/test_transaction_sender.py | 386 ++++++++++++++++++ 12 files changed, 1801 insertions(+), 118 deletions(-) create mode 100644 tests/assets/__init__.py create mode 100644 tests/assets/test_asset_manager.py create mode 100644 tests/transactions/test_transaction_creator.py create mode 100644 tests/transactions/test_transaction_sender.py diff --git a/src/algokit_utils/applications/app_manager.py b/src/algokit_utils/applications/app_manager.py index 91b0a407..307d5e0f 100644 --- a/src/algokit_utils/applications/app_manager.py +++ b/src/algokit_utils/applications/app_manager.py @@ -268,6 +268,27 @@ def get_box_values_from_abi_type( ) -> list[ABIValue]: return [self.get_box_value_from_abi_type(app_id, box_name, abi_type) for box_name in box_names] + @staticmethod + def get_abi_return( + confirmation: algosdk.v2client.algod.AlgodResponseType, method: algosdk.abi.Method | None = None + ) -> ABIValue | None: + """Get the ABI return value from a transaction confirmation.""" + if not method: + return None + + # Use the SDK's built-in ABI result parsing + atc = algosdk.atomic_transaction_composer.AtomicTransactionComposer() + abi_result = atc.parse_result( + method, # Map of transaction index to ABI method + "dummy_txn", # List of transaction info + confirmation, # type: ignore[arg-type] + ) + + if not abi_result: + return None + + return abi_result.return_value # type: ignore[no-any-return] + @staticmethod def decode_app_state(state: list[dict[str, Any]]) -> dict[str, AppState]: state_values: dict[str, AppState] = {} diff --git a/src/algokit_utils/assets/asset_manager.py b/src/algokit_utils/assets/asset_manager.py index 4bef4802..ee642dac 100644 --- a/src/algokit_utils/assets/asset_manager.py +++ b/src/algokit_utils/assets/asset_manager.py @@ -1,2 +1,267 @@ +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +import algosdk +from algosdk.atomic_transaction_composer import AccountTransactionSigner, TransactionSigner +from algosdk.v2client import algod + +from algokit_utils.models.account import Account +from algokit_utils.transactions.transaction_composer import ( + AssetOptInParams, + AssetOptOutParams, + TransactionComposer, +) + + +@dataclass(frozen=True) +class AccountAssetInformation: + """Information about an account's holding of a particular asset.""" + + asset_id: int + """The ID of the asset.""" + balance: int + """The amount of the asset held by the account.""" + frozen: bool + """Whether the asset is frozen for this account.""" + round: int + """The round this information was retrieved at.""" + + +@dataclass(frozen=True) +class AssetInformation: + """Information about an asset.""" + + asset_id: int + """The ID of the asset.""" + creator: str + """The address of the account that created the asset.""" + total: int + """The total amount of the smallest divisible units that were created of the asset.""" + decimals: int + """The amount of decimal places the asset was created with.""" + default_frozen: bool | None = None + """Whether the asset was frozen by default for all accounts.""" + manager: str | None = None + """The address of the optional account that can manage the configuration of the asset and destroy it.""" + reserve: str | None = None + """The address of the optional account that holds the reserve (uncirculated supply) units of the asset.""" + freeze: str | None = None + """The address of the optional account that can be used to freeze or unfreeze holdings of this asset.""" + clawback: str | None = None + """The address of the optional account that can clawback holdings of this asset from any account.""" + unit_name: str | None = None + """The optional name of the unit of this asset (e.g. ticker name).""" + unit_name_b64: bytes | None = None + """The optional name of the unit of this asset as bytes.""" + asset_name: str | None = None + """The optional name of the asset.""" + asset_name_b64: bytes | None = None + """The optional name of the asset as bytes.""" + url: str | None = None + """Optional URL where more information about the asset can be retrieved.""" + url_b64: bytes | None = None + """Optional URL where more information about the asset can be retrieved as bytes.""" + metadata_hash: bytes | None = None + """32-byte hash of some metadata that is relevant to the asset and/or asset holders.""" + + +@dataclass(frozen=True) +class BulkAssetOptInOutResult: + """Individual result from performing a bulk opt-in or bulk opt-out for an account against a series of assets.""" + + asset_id: int + """The ID of the asset opted into / out of""" + transaction_id: str + """The transaction ID of the resulting opt in / out""" + + class AssetManager: - """A manager for Algorand assets""" + """A manager for Algorand assets.""" + + def __init__(self, algod_client: algod.AlgodClient, new_group: Callable[[], TransactionComposer]): + """Create a new asset manager. + + Args: + algod_client: An algod client + new_group: A function that creates a new `TransactionComposer` transaction group + """ + self._algod = algod_client + self._new_group = new_group + + def get_by_id(self, asset_id: int) -> AssetInformation: + """Returns the current asset information for the asset with the given ID. + + Args: + asset_id: The ID of the asset + + Returns: + The asset information + """ + asset = self._algod.asset_info(asset_id) + assert isinstance(asset, dict) + params = asset["params"] + + return AssetInformation( + asset_id=asset_id, + total=params["total"], + decimals=params["decimals"], + asset_name=params.get("name"), + asset_name_b64=params.get("name-b64"), + unit_name=params.get("unit-name"), + unit_name_b64=params.get("unit-name-b64"), + url=params.get("url"), + url_b64=params.get("url-b64"), + creator=params["creator"], + manager=params.get("manager"), + clawback=params.get("clawback"), + freeze=params.get("freeze"), + reserve=params.get("reserve"), + default_frozen=params.get("default-frozen"), + metadata_hash=params.get("metadata-hash"), + ) + + def get_account_information( + self, sender: str | Account | TransactionSigner, asset_id: int + ) -> AccountAssetInformation: + """Returns the given sender account's asset holding for a given asset. + + Args: + sender: The address of the sender/account to look up + asset_id: The ID of the asset to return a holding for + + Returns: + The account asset holding information + """ + address = self._get_address_from_sender(sender) + info = self._algod.account_asset_info(address, asset_id) + assert isinstance(info, dict) + + return AccountAssetInformation( + asset_id=asset_id, + balance=info["asset-holding"]["amount"], + frozen=info["asset-holding"]["is-frozen"], + round=info["round"], + ) + + def bulk_opt_in( + self, + account: str | Account | TransactionSigner, + asset_ids: list[int], + *, + suppress_log: bool = False, + **transaction_params: Any, + ) -> list[BulkAssetOptInOutResult]: + """Opt an account in to a list of Algorand Standard Assets. + + Args: + account: The account to opt-in + asset_ids: The list of asset IDs to opt-in to + suppress_log: Whether to suppress logging + **transaction_params: Any additional transaction parameters + + Returns: + An array of records matching asset ID to transaction ID of the opt in + """ + results: list[BulkAssetOptInOutResult] = [] + sender = self._get_address_from_sender(account) + + for asset_group in _chunk_array(asset_ids, algosdk.constants.TX_GROUP_LIMIT): + composer = self._new_group() + + for asset_id in asset_group: + params = AssetOptInParams( + sender=sender, + asset_id=asset_id, + **transaction_params, + ) + composer.add_asset_opt_in(params) + + result = composer.send(suppress_log=suppress_log) + + for i, asset_id in enumerate(asset_group): + results.append(BulkAssetOptInOutResult(asset_id=asset_id, transaction_id=result.tx_ids[i])) + + return results + + def bulk_opt_out( # noqa: C901 + self, + account: str | Account | TransactionSigner, + asset_ids: list[int], + *, + ensure_zero_balance: bool = True, + suppress_log: bool = False, + **transaction_params: Any, + ) -> list[BulkAssetOptInOutResult]: + """Opt an account out of a list of Algorand Standard Assets. + + Args: + account: The account to opt-out + asset_ids: The list of asset IDs to opt-out of + ensure_zero_balance: Whether to check if the account has a zero balance first + suppress_log: Whether to suppress logging + **transaction_params: Any additional transaction parameters + + Returns: + An array of records matching asset ID to transaction ID of the opt out + """ + results: list[BulkAssetOptInOutResult] = [] + sender = self._get_address_from_sender(account) + + for asset_group in _chunk_array(asset_ids, algosdk.constants.TX_GROUP_LIMIT): + composer = self._new_group() + + not_opted_in_asset_ids: list[int] = [] + non_zero_balance_asset_ids: list[int] = [] + + if ensure_zero_balance: + for asset_id in asset_group: + try: + account_asset_info = self.get_account_information(sender, asset_id) + if account_asset_info.balance != 0: + non_zero_balance_asset_ids.append(asset_id) + except Exception: + not_opted_in_asset_ids.append(asset_id) + + if not_opted_in_asset_ids or non_zero_balance_asset_ids: + error_message = f"Account {sender}" + if not_opted_in_asset_ids: + error_message += f" is not opted-in to Asset(s) {', '.join(map(str, not_opted_in_asset_ids))}" + if non_zero_balance_asset_ids: + error_message += ( + f" has non-zero balance for Asset(s) {', '.join(map(str, non_zero_balance_asset_ids))}" + ) + error_message += "; can't opt-out." + raise ValueError(error_message) + + for asset_id in asset_group: + asset_info = self.get_by_id(asset_id) + params = AssetOptOutParams( + sender=sender, + asset_id=asset_id, + creator=asset_info.creator, + **transaction_params, + ) + composer.add_asset_opt_out(params) + + result = composer.send(suppress_log=suppress_log) + + for i, asset_id in enumerate(asset_group): + results.append(BulkAssetOptInOutResult(asset_id=asset_id, transaction_id=result.tx_ids[i])) + + return results + + @staticmethod + def _get_address_from_sender(sender: str | Account | TransactionSigner) -> str: + if isinstance(sender, str): + return sender + if isinstance(sender, Account): + return sender.address + if isinstance(sender, AccountTransactionSigner): + return str(algosdk.account.address_from_private_key(sender.private_key)) + raise ValueError(f"Unsupported sender type: {type(sender)}") + + +def _chunk_array(array: list, size: int) -> list[list]: + """Split an array into chunks of the given size.""" + return [array[i : i + size] for i in range(0, len(array), size)] diff --git a/src/algokit_utils/clients/algorand_client.py b/src/algokit_utils/clients/algorand_client.py index f4851daf..f679c95e 100644 --- a/src/algokit_utils/clients/algorand_client.py +++ b/src/algokit_utils/clients/algorand_client.py @@ -1,11 +1,9 @@ import copy import time -from collections.abc import Callable -from dataclasses import dataclass from typing import Any from algosdk.atomic_transaction_composer import AtomicTransactionResponse, TransactionSigner -from algosdk.transaction import SuggestedParams, Transaction, wait_for_confirmation +from algosdk.transaction import SuggestedParams, wait_for_confirmation from typing_extensions import Self from algokit_utils.accounts.account_manager import AccountManager @@ -51,57 +49,23 @@ ] -@dataclass -class AlgorandClientSendMethods: - """ - Methods used to send a transaction to the network and wait for confirmation - """ - - payment: Callable[[PaymentParams], dict[str, Any]] - asset_create: Callable[[AssetCreateParams], dict[str, Any]] - asset_config: Callable[[AssetConfigParams], dict[str, Any]] - asset_freeze: Callable[[AssetFreezeParams], dict[str, Any]] - asset_destroy: Callable[[AssetDestroyParams], dict[str, Any]] - asset_transfer: Callable[[AssetTransferParams], dict[str, Any]] - app_call: Callable[[AppCallParams], dict[str, Any]] - online_key_reg: Callable[[OnlineKeyRegistrationParams], dict[str, Any]] - method_call: Callable[[AppMethodCallParams], dict[str, Any]] - asset_opt_in: Callable[[AssetOptInParams], dict[str, Any]] - - -@dataclass -class AlgorandClientTransactionMethods: - """ - Methods used to form a transaction without signing or sending to the network - """ - - payment: Callable[[PaymentParams], Transaction] - asset_create: Callable[[AssetCreateParams], Transaction] - asset_config: Callable[[AssetConfigParams], Transaction] - asset_freeze: Callable[[AssetFreezeParams], Transaction] - asset_destroy: Callable[[AssetDestroyParams], Transaction] - asset_transfer: Callable[[AssetTransferParams], Transaction] - app_call: Callable[[AppCallParams], Transaction] - online_key_reg: Callable[[OnlineKeyRegistrationParams], Transaction] - method_call: Callable[[AppMethodCallParams], list[Transaction]] - asset_opt_in: Callable[[AssetOptInParams], Transaction] - - class AlgorandClient: """A client that brokers easy access to Algorand functionality.""" def __init__(self, config: AlgoClientConfigs | AlgoSdkClients): self._client_manager: ClientManager = ClientManager(config) self._account_manager: AccountManager = AccountManager(self._client_manager) - self._asset_manager: AssetManager = AssetManager() # TODO: implement - self._app_manager: AppManager = AppManager(self._client_manager.algod) # TODO: implement + self._asset_manager: AssetManager = AssetManager(self._client_manager.algod, lambda: self.new_group()) + self._app_manager: AppManager = AppManager(self._client_manager.algod) self._transaction_sender = AlgorandClientTransactionSender( new_group=lambda: self.new_group(), asset_manager=self._asset_manager, app_manager=self._app_manager, algod_client=self._client_manager.algod, ) - self._transaction_creator = AlgorandClientTransactionCreator() # TODO: implement + self._transaction_creator = AlgorandClientTransactionCreator( + new_group=lambda: self.new_group(), + ) self._cached_suggested_params: SuggestedParams | None = None self._cached_suggested_params_expiry: float | None = None @@ -109,12 +73,6 @@ def __init__(self, config: AlgoClientConfigs | AlgoSdkClients): self._default_validity_window: int = 10 - def _unwrap_single_send_result(self, results: AtomicTransactionResponse) -> dict[str, Any]: - return { - "confirmation": wait_for_confirmation(self._client_manager.algod, results.tx_ids[0]), - "tx_id": results.tx_ids[0], - } - def set_default_validity_window(self, validity_window: int) -> Self: """ Sets the default validity window for transactions. @@ -180,6 +138,15 @@ def get_suggested_params(self) -> SuggestedParams: return copy.deepcopy(self._cached_suggested_params) + def new_group(self) -> TransactionComposer: + """Start a new `TransactionComposer` transaction group""" + return TransactionComposer( + algod=self.client.algod, + get_signer=lambda addr: self.account.get_signer(addr), + get_suggested_params=self.get_suggested_params, + default_validity_window=self._default_validity_window, + ) + @property def client(self) -> ClientManager: """Get clients, including algosdk clients and app clients.""" @@ -190,14 +157,15 @@ def account(self) -> AccountManager: """Get or create accounts that can sign transactions.""" return self._account_manager - def new_group(self) -> TransactionComposer: - """Start a new `TransactionComposer` transaction group""" - return TransactionComposer( - algod=self.client.algod, - get_signer=lambda addr: self.account.get_signer(addr), - get_suggested_params=self.get_suggested_params, - default_validity_window=self._default_validity_window, - ) + @property + def asset(self) -> AssetManager: + """Get or create assets.""" + return self._asset_manager + + @property + def app_deployer(self) -> AppManager: + """Get or create applications.""" + return self._app_manager @property def send(self) -> AlgorandClientTransactionSender: @@ -209,6 +177,12 @@ def create_transaction(self) -> AlgorandClientTransactionCreator: """Methods for building transactions""" return self._transaction_creator + def _unwrap_single_send_result(self, results: AtomicTransactionResponse) -> dict[str, Any]: + return { + "confirmation": wait_for_confirmation(self._client_manager.algod, results.tx_ids[0]), + "tx_id": results.tx_ids[0], + } + @staticmethod def default_local_net() -> "AlgorandClient": """ diff --git a/src/algokit_utils/transactions/transaction_composer.py b/src/algokit_utils/transactions/transaction_composer.py index 2d36c06d..77dea2e9 100644 --- a/src/algokit_utils/transactions/transaction_composer.py +++ b/src/algokit_utils/transactions/transaction_composer.py @@ -1,8 +1,9 @@ from __future__ import annotations +import logging import math from dataclasses import dataclass -from typing import TYPE_CHECKING, Union +from typing import TYPE_CHECKING, Any, Union import algosdk import algosdk.atomic_transaction_composer @@ -11,7 +12,9 @@ TransactionSigner, TransactionWithSigner, ) -from algosdk.transaction import OnComplete +from algosdk.error import AlgodHTTPError +from algosdk.transaction import OnComplete, Transaction +from algosdk.v2client.algod import AlgodClient from deprecated import deprecated from algokit_utils._debugging import simulate_and_persist_response, simulate_response @@ -29,6 +32,8 @@ from algokit_utils.models.amount import AlgoAmount from algokit_utils.transactions.models import Arc2TransactionNote +logger = logging.getLogger(__name__) + @dataclass(frozen=True) class SenderParam: @@ -565,6 +570,136 @@ class TransactionComposerBuildResult: method_calls: dict[int, Method] +@dataclass +class SendAtomicTransactionComposerResults: + """Results from sending an AtomicTransactionComposer transaction group""" + + group_id: str | None + """The group ID if this was a transaction group""" + confirmations: list[algosdk.v2client.algod.AlgodResponseType] + """The confirmation info for each transaction""" + tx_ids: list[str] + """The transaction IDs that were sent""" + transactions: list[Transaction] + """The transactions that were sent""" + returns: list[Any] + """The ABI return values from any ABI method calls""" + + +def send_atomic_transaction_composer( # noqa: C901, PLR0912, PLR0913 + atc: AtomicTransactionComposer, + algod: AlgodClient, + *, + max_rounds_to_wait: int | None = 5, + skip_waiting: bool = False, + suppress_log: bool = False, + populate_resources: bool | None = None, # TODO: implement/clarify # noqa: ARG001 +) -> SendAtomicTransactionComposerResults: + """Send an AtomicTransactionComposer transaction group + + Args: + atc: The AtomicTransactionComposer to send + algod: The Algod client to use + max_rounds_to_wait: Maximum number of rounds to wait for confirmation + skip_waiting: If True, don't wait for transaction confirmation + suppress_log: If True, suppress logging + populate_resources: If True, populate app call resources + + Returns: + The results of sending the transaction group + + Raises: + Exception: If there is an error sending the transactions + """ + + try: + # Build transactions + transactions_with_signer = atc.build_group() + transactions_to_send = [t.txn for t in transactions_with_signer] + + # Get group ID if multiple transactions + group_id = None + if len(transactions_to_send) > 1: + group_id = transactions_to_send[0].group.hex() if transactions_to_send[0].group else None + + if not suppress_log: + logger.info(f"Sending group of {len(transactions_to_send)} transactions ({group_id})") + logger.debug(f"Transaction IDs ({group_id}): {[t.get_txid() for t in transactions_to_send]}") + + # Simulate if debug enabled + if config.debug and config.trace_all and config.project_root: + simulate_and_persist_response( + atc, + config.project_root, + algod, + config.trace_buffer_size_mb, + ) + + # Execute transactions + result = atc.execute(algod, wait_rounds=max_rounds_to_wait or 5) + + # Log results + if not suppress_log: + if len(transactions_to_send) > 1: + logger.info(f"Group transaction ({group_id}) sent with {len(transactions_to_send)} transactions") + else: + logger.info(f"Sent transaction ID {transactions_to_send[0].get_txid()}") + + # Get confirmations if not skipping + confirmations = None + if not skip_waiting: + confirmations = [algod.pending_transaction_info(t.get_txid()) for t in transactions_to_send] + + # Return results + return SendAtomicTransactionComposerResults( + group_id=group_id, + confirmations=confirmations or [], + tx_ids=[t.get_txid() for t in transactions_to_send], + transactions=transactions_to_send, + returns=[r.return_value for r in result.abi_results], + ) + + except AlgodHTTPError as e: + # Handle error with debug info if enabled + if config.debug: + logger.error( + "Received error executing Atomic Transaction Composer and debug flag enabled; " + "attempting simulation to get more information" + ) + + simulate = None + if config.project_root and not config.trace_all: + # Only simulate if trace_all is disabled and project_root is set + simulate = simulate_and_persist_response(atc, config.project_root, algod, config.trace_buffer_size_mb) + else: + simulate = simulate_response(atc, algod) + + traces = [] + if simulate and simulate.failed_at: + for txn_group in simulate.simulate_response["txn-groups"]: + app_budget = txn_group.get("app-budget-added") + app_budget_consumed = txn_group.get("app-budget-consumed") + failure_message = txn_group.get("failure-message") + txn_result = txn_group.get("txn-results", [{}])[0] + exec_trace = txn_result.get("exec-trace", {}) + + traces.append( + { + "trace": exec_trace, + "app_budget": app_budget, + "app_budget_consumed": app_budget_consumed, + "failure_message": failure_message, + } + ) + + error = Exception(f"Transaction failed: {e}") + error.traces = traces # type: ignore[attr-defined] + raise error from e + + logger.error("Received error executing Atomic Transaction Composer, for more information enable the debug flag") + raise Exception(f"Transaction failed: {e}") from e + + class TransactionComposer: """ A class for composing and managing Algorand transactions using the Algosdk library. @@ -754,15 +889,18 @@ def execute( self, *, max_rounds_to_wait: int | None = None, - ) -> algosdk.atomic_transaction_composer.AtomicTransactionResponse: + ) -> SendAtomicTransactionComposerResults: return self.send( max_rounds_to_wait=max_rounds_to_wait, ) def send( self, + *, max_rounds_to_wait: int | None = None, - ) -> algosdk.atomic_transaction_composer.AtomicTransactionResponse: + suppress_log: bool = False, + populate_app_call_resources: bool = False, + ) -> SendAtomicTransactionComposerResults: group = self.build().transactions wait_rounds = max_rounds_to_wait @@ -772,7 +910,13 @@ def send( wait_rounds = last_round - first_round + 1 try: - return self.atc.execute(client=self.algod, wait_rounds=wait_rounds) # TODO: reimplement ATC + return send_atomic_transaction_composer( + self.atc, + self.algod, + max_rounds_to_wait=wait_rounds, + suppress_log=suppress_log, + populate_resources=populate_app_call_resources, + ) except algosdk.error.AlgodHTTPError as e: raise Exception(f"Transaction failed: {e}") from e @@ -824,7 +968,7 @@ def _common_txn_build_step( if params.lease: txn.lease = params.lease if params.rekey_to: - txn.rekey_to = algosdk.encoding.decode_address(params.rekey_to) + txn.rekey_to = params.rekey_to if params.note: txn.note = params.note @@ -1142,6 +1286,14 @@ def _build_txn( # noqa: C901, PLR0912, PLR0911 AssetTransferParams(**txn.__dict__, receiver=txn.sender, amount=0), suggested_params ) return [TransactionWithSigner(txn=asset_transfer, signer=signer)] + case AssetOptOutParams(): + txn_dict = txn.__dict__ + creator = txn_dict.pop("creator") + asset_transfer = self._build_asset_transfer( + AssetTransferParams(**txn_dict, receiver=txn.sender, amount=0, close_asset_to=creator), + suggested_params, + ) + return [TransactionWithSigner(txn=asset_transfer, signer=signer)] case OnlineKeyRegistrationParams(): key_reg = self._build_key_reg(txn, suggested_params) return [TransactionWithSigner(txn=key_reg, signer=signer)] diff --git a/src/algokit_utils/transactions/transaction_creator.py b/src/algokit_utils/transactions/transaction_creator.py index e4ae0e03..a5bc8926 100644 --- a/src/algokit_utils/transactions/transaction_creator.py +++ b/src/algokit_utils/transactions/transaction_creator.py @@ -1,2 +1,150 @@ +from collections.abc import Callable +from typing import TypeVar + +from algosdk.transaction import Transaction + +from algokit_utils.transactions.transaction_composer import ( + AppCallMethodCall, + AppCallParams, + AppCreateMethodCall, + AppCreateParams, + AppDeleteMethodCall, + AppDeleteParams, + AppUpdateMethodCall, + AppUpdateParams, + AssetConfigParams, + AssetCreateParams, + AssetDestroyParams, + AssetFreezeParams, + AssetOptInParams, + AssetOptOutParams, + AssetTransferParams, + BuiltTransactions, + OnlineKeyRegistrationParams, + PaymentParams, + TransactionComposer, +) + +TxnParam = TypeVar("TxnParam") +TxnResult = TypeVar("TxnResult") + + class AlgorandClientTransactionCreator: - """A creator for Algorand transactions""" + """A creator for Algorand transactions.""" + + def __init__(self, new_group: Callable[[], TransactionComposer]) -> None: + """ + Creates a new `AlgorandClientTransactionCreator`. + + Args: + new_group: A lambda that starts a new `TransactionComposer` transaction group + """ + self._new_group = new_group + + def _transaction( + self, c: Callable[[TransactionComposer], Callable[[TxnParam], TransactionComposer]] + ) -> Callable[[TxnParam], Transaction]: + """Generic method to create a single transaction.""" + + def create_transaction(params: TxnParam) -> Transaction: + composer = self._new_group() + result = c(composer)(params).build_transactions() + return result.transactions[-1] + + return create_transaction + + def _transactions( + self, c: Callable[[TransactionComposer], Callable[[TxnParam], TransactionComposer]] + ) -> Callable[[TxnParam], BuiltTransactions]: + """Generic method to create multiple transactions.""" + + def create_transactions(params: TxnParam) -> BuiltTransactions: + composer = self._new_group() + return c(composer)(params).build_transactions() + + return create_transactions + + @property + def payment(self) -> Callable[[PaymentParams], Transaction]: + """Create a payment transaction to transfer Algo between accounts.""" + return self._transaction(lambda c: c.add_payment) + + @property + def asset_create(self) -> Callable[[AssetCreateParams], Transaction]: + """Create a create Algorand Standard Asset transaction.""" + return self._transaction(lambda c: c.add_asset_create) + + @property + def asset_config(self) -> Callable[[AssetConfigParams], Transaction]: + """Create an asset config transaction to reconfigure an existing Algorand Standard Asset.""" + return self._transaction(lambda c: c.add_asset_config) + + @property + def asset_freeze(self) -> Callable[[AssetFreezeParams], Transaction]: + """Create an Algorand Standard Asset freeze transaction.""" + return self._transaction(lambda c: c.add_asset_freeze) + + @property + def asset_destroy(self) -> Callable[[AssetDestroyParams], Transaction]: + """Create an Algorand Standard Asset destroy transaction.""" + return self._transaction(lambda c: c.add_asset_destroy) + + @property + def asset_transfer(self) -> Callable[[AssetTransferParams], Transaction]: + """Create an Algorand Standard Asset transfer transaction.""" + return self._transaction(lambda c: c.add_asset_transfer) + + @property + def asset_opt_in(self) -> Callable[[AssetOptInParams], Transaction]: + """Create an Algorand Standard Asset opt-in transaction.""" + return self._transaction(lambda c: c.add_asset_opt_in) + + @property + def asset_opt_out(self) -> Callable[[AssetOptOutParams], Transaction]: + """Create an asset opt-out transaction.""" + return self._transaction(lambda c: c.add_asset_opt_out) + + @property + def app_create(self) -> Callable[[AppCreateParams], Transaction]: + """Create an application create transaction.""" + return self._transaction(lambda c: c.add_app_create) + + @property + def app_update(self) -> Callable[[AppUpdateParams], Transaction]: + """Create an application update transaction.""" + return self._transaction(lambda c: c.add_app_update) + + @property + def app_delete(self) -> Callable[[AppDeleteParams], Transaction]: + """Create an application delete transaction.""" + return self._transaction(lambda c: c.add_app_delete) + + @property + def app_call(self) -> Callable[[AppCallParams], Transaction]: + """Create an application call transaction.""" + return self._transaction(lambda c: c.add_app_call) + + @property + def app_create_method_call(self) -> Callable[[AppCreateMethodCall], BuiltTransactions]: + """Create an application create call with ABI method call transaction.""" + return self._transactions(lambda c: c.add_app_create_method_call) + + @property + def app_update_method_call(self) -> Callable[[AppUpdateMethodCall], BuiltTransactions]: + """Create an application update call with ABI method call transaction.""" + return self._transactions(lambda c: c.add_app_update_method_call) + + @property + def app_delete_method_call(self) -> Callable[[AppDeleteMethodCall], BuiltTransactions]: + """Create an application delete call with ABI method call transaction.""" + return self._transactions(lambda c: c.add_app_delete_method_call) + + @property + def app_call_method_call(self) -> Callable[[AppCallMethodCall], BuiltTransactions]: + """Create an application call with ABI method call transaction.""" + return self._transactions(lambda c: c.add_app_call_method_call) + + @property + def online_key_registration(self) -> Callable[[OnlineKeyRegistrationParams], Transaction]: + """Create an online key registration transaction.""" + return self._transaction(lambda c: c.add_online_key_registration) diff --git a/src/algokit_utils/transactions/transaction_sender.py b/src/algokit_utils/transactions/transaction_sender.py index 41c5aa4b..3050dcb7 100644 --- a/src/algokit_utils/transactions/transaction_sender.py +++ b/src/algokit_utils/transactions/transaction_sender.py @@ -1,30 +1,86 @@ -import logging from collections.abc import Callable from dataclasses import dataclass -from typing import Any, Generic, TypeVar +from logging import getLogger +from typing import Any, TypedDict, TypeVar -from algosdk.transaction import wait_for_confirmation -from algosdk.v2client.algod import AlgodClient +import algosdk +import algosdk.atomic_transaction_composer +from algosdk.atomic_transaction_composer import AtomicTransactionResponse +from algosdk.transaction import Transaction from algokit_utils.applications.app_manager import AppManager from algokit_utils.assets.asset_manager import AssetManager +from algokit_utils.models.abi import ABIValue from algokit_utils.transactions.transaction_composer import ( + AppCallMethodCall, + AppCallParams, + AppCreateMethodCall, + AppCreateParams, + AppDeleteMethodCall, + AppDeleteParams, + AppUpdateMethodCall, + AppUpdateParams, + AssetConfigParams, + AssetCreateParams, + AssetDestroyParams, + AssetFreezeParams, + AssetOptInParams, + AssetOptOutParams, + AssetTransferParams, + OnlineKeyRegistrationParams, PaymentParams, TransactionComposer, + TxnParams, ) -logger = logging.getLogger(__name__) -TxnParam = TypeVar("TxnParam") -TxnResult = TypeVar("TxnResult") +logger = getLogger(__name__) @dataclass -class SendTransactionResult(Generic[TxnResult]): - """Result of sending a transaction""" +class SendSingleTransactionResult: + tx_id: str # Single transaction ID (last from txIds array) + transaction: Transaction # Last transaction + confirmation: algosdk.v2client.algod.AlgodResponseType # Last confirmation - confirmation: dict[str, Any] - tx_id: str - return_value: TxnResult | None = None + # Fields from SendAtomicTransactionComposerResults + group_id: str + tx_ids: list[str] # Full array of transaction IDs + transactions: list[Transaction] + confirmations: list[algosdk.v2client.algod.AlgodResponseType] + returns: list[algosdk.atomic_transaction_composer.ABIResult] | None = None + + # Fields from AssetCreateParams + asset_id: int | None = None + + +@dataclass +class SendAppTransactionResult(SendSingleTransactionResult): + return_value: ABIValue | None = None + + +@dataclass +class SendAppUpdateTransactionResult(SendAppTransactionResult): + compiled_approval: Any | None = None + compiled_clear: Any | None = None + + +@dataclass +class _RequiredSendAppTransactionResult: + app_id: int + app_address: str + + +@dataclass +class SendAppCreateTransactionResult(SendAppUpdateTransactionResult, _RequiredSendAppTransactionResult): + pass + + +class LogConfig(TypedDict, total=False): + pre_log: Callable[[TxnParams, Transaction], str] + post_log: Callable[[TxnParams, AtomicTransactionResponse], str] + + +T = TypeVar("T", bound=TxnParams) class AlgorandClientTransactionSender: @@ -35,54 +91,265 @@ def __init__( new_group: Callable[[], TransactionComposer], asset_manager: AssetManager, app_manager: AppManager, - algod_client: AlgodClient, + algod_client: algosdk.v2client.algod.AlgodClient, ) -> None: self._new_group = new_group self._asset_manager = asset_manager self._app_manager = app_manager - self._algod_client = algod_client + self._algod = algod_client def new_group(self) -> TransactionComposer: - """Create a new transaction group""" return self._new_group() def _send( self, - c: Callable[[TransactionComposer], Callable[[TxnParam], TransactionComposer]], - log: dict[str, Callable[[TxnParam, Any], str]] | None = None, - ) -> Callable[[TxnParam], SendTransactionResult[Any]]: - """Generic method to send transactions with logging.""" - - def send_transaction(params: TxnParam) -> SendTransactionResult[Any]: - composer = self._new_group() + c: Callable[[TransactionComposer], Callable[[T], TransactionComposer]], + pre_log: Callable[[T, Transaction], str] | None = None, + post_log: Callable[[T, SendSingleTransactionResult], str] | None = None, + ) -> Callable[[T], SendSingleTransactionResult]: + def send_transaction(params: T) -> SendSingleTransactionResult: + composer = self.new_group() c(composer)(params) - if log and log.get("pre_log"): + if pre_log: transaction = composer.build().transactions[-1].txn - logger.debug(log["pre_log"](params, transaction)) - - result = composer.send() + logger.debug(pre_log(params, transaction)) - if log and log.get("post_log"): - logger.debug(log["post_log"](params, result)) + raw_result = composer.send() - confirmation = wait_for_confirmation(self._algod_client, result.tx_ids[0]) - return SendTransactionResult( - confirmation=confirmation, - tx_id=result.tx_ids[0], + result = SendSingleTransactionResult( + **raw_result.__dict__, + confirmation=raw_result.confirmations[-1], + transaction=raw_result.transactions[-1], + tx_id=raw_result.tx_ids[-1], ) + if post_log: + logger.debug(post_log(params, result)) + + return result + return send_transaction - @property - def payment(self) -> Callable[[PaymentParams], SendTransactionResult[None]]: - """Send a payment transaction""" + def _send_app_call( + self, + c: Callable[[TransactionComposer], Callable[[T], TransactionComposer]], + pre_log: Callable[[T, Transaction], str] | None = None, + post_log: Callable[[T, SendSingleTransactionResult], str] | None = None, + ) -> Callable[[T], SendAppTransactionResult]: + def send_app_call(params: T) -> SendAppTransactionResult: + result = self._send(c, pre_log, post_log)(params) + return SendAppTransactionResult( + **result.__dict__, + return_value=AppManager.get_abi_return(result.confirmation, getattr(params, "method", None)), + ) + + return send_app_call + + def _send_app_update_call( + self, + c: Callable[[TransactionComposer], Callable[[T], TransactionComposer]], + pre_log: Callable[[T, Transaction], str] | None = None, + post_log: Callable[[T, SendSingleTransactionResult], str] | None = None, + ) -> Callable[[T], SendAppUpdateTransactionResult]: + def send_app_update_call(params: T) -> SendAppUpdateTransactionResult: + result = self._send_app_call(c, pre_log, post_log)(params) + + if not isinstance(params, AppCreateParams | AppUpdateParams | AppCreateMethodCall | AppUpdateMethodCall): + raise TypeError("Invalid parameter type") + + compiled_approval = ( + self._app_manager.get_compilation_result(params.approval_program) + if isinstance(params.approval_program, str) + else None + ) + compiled_clear = ( + self._app_manager.get_compilation_result(params.clear_state_program) + if isinstance(params.clear_state_program, str) + else None + ) + + return SendAppUpdateTransactionResult( + **result.__dict__, + compiled_approval=compiled_approval, + compiled_clear=compiled_clear, + ) + + return send_app_update_call + + def _send_app_create_call( + self, + c: Callable[[TransactionComposer], Callable[[T], TransactionComposer]], + pre_log: Callable[[T, Transaction], str] | None = None, + post_log: Callable[[T, SendSingleTransactionResult], str] | None = None, + ) -> Callable[[T], SendAppCreateTransactionResult]: + def send_app_create_call(params: T) -> SendAppCreateTransactionResult: + result = self._send_app_update_call(c, pre_log, post_log)(params) + app_id = int(result.confirmation["application-index"]) # type: ignore[call-overload] + + return SendAppCreateTransactionResult( + **result.__dict__, + app_id=app_id, + app_address=algosdk.logic.get_application_address(app_id), + ) + + return send_app_create_call + + def _get_method_call_for_log(self, method: algosdk.abi.Method, args: list[Any]) -> str: + """Helper function to format method call logs similar to TypeScript version""" + args_str = str([str(a) if not isinstance(a, bytes | bytearray) else a.hex() for a in args]) + return f"{method.name}({args_str})" + + def payment(self, params: PaymentParams) -> SendSingleTransactionResult: + """Send a payment transaction to transfer Algo between accounts.""" return self._send( lambda c: c.add_payment, - { - "pre_log": lambda params, txn: ( - f"Sending {params.amount.micro_algos} µALGO from {params.sender} " - f"to {params.receiver} via transaction {txn.get_txid()}" - ) - }, + pre_log=lambda params, transaction: ( + f"Sending {params.amount} from {params.sender} to {params.receiver} " + f"via transaction {transaction.get_txid()}" + ), + )(params) + + def asset_create(self, params: AssetCreateParams) -> SendSingleTransactionResult: + """Create a new Algorand Standard Asset.""" + result = self._send( + lambda c: c.add_asset_create, + post_log=lambda params, result: ( + f"Created asset{f' {params.asset_name}' if hasattr(params, 'asset_name') else ''}" + f"{f' ({params.unit_name})' if hasattr(params, 'unit_name') else ''} with " + f"{params.total} units and {getattr(params, 'decimals', 0)} decimals created by " + f"{params.sender} with ID {result.confirmation['asset-index']} via transaction " # type: ignore[call-overload] + f"{result.tx_ids[-1]}" + ), + )(params) + + result = SendSingleTransactionResult( + **result.__dict__, ) + result.asset_id = int(result.confirmation["asset-index"]) # type: ignore[call-overload] + return result + + def asset_config(self, params: AssetConfigParams) -> SendSingleTransactionResult: + """Configure an existing Algorand Standard Asset.""" + return self._send( + lambda c: c.add_asset_config, + pre_log=lambda params, transaction: ( + f"Configuring asset with ID {params.asset_id} via transaction {transaction.get_txid()}" + ), + )(params) + + def asset_freeze(self, params: AssetFreezeParams) -> SendSingleTransactionResult: + """Freeze or unfreeze an Algorand Standard Asset for an account.""" + return self._send( + lambda c: c.add_asset_freeze, + pre_log=lambda params, transaction: ( + f"Freezing asset with ID {params.asset_id} via transaction {transaction.get_txid()}" + ), + )(params) + + def asset_destroy(self, params: AssetDestroyParams) -> SendSingleTransactionResult: + """Destroys an Algorand Standard Asset.""" + return self._send( + lambda c: c.add_asset_destroy, + pre_log=lambda params, transaction: ( + f"Destroying asset with ID {params.asset_id} via transaction {transaction.get_txid()}" + ), + )(params) + + def asset_transfer(self, params: AssetTransferParams) -> SendSingleTransactionResult: + """Transfer an Algorand Standard Asset.""" + return self._send( + lambda c: c.add_asset_transfer, + pre_log=lambda params, transaction: ( + f"Transferring {params.amount} units of asset with ID {params.asset_id} from " + f"{params.sender} to {params.receiver} via transaction {transaction.get_txid()}" + ), + )(params) + + def asset_opt_in(self, params: AssetOptInParams) -> SendSingleTransactionResult: + """Opt an account into an Algorand Standard Asset.""" + return self._send( + lambda c: c.add_asset_opt_in, + pre_log=lambda params, transaction: ( + f"Opting in {params.sender} to asset with ID {params.asset_id} via transaction " + f"{transaction.get_txid()}" + ), + )(params) + + def asset_opt_out( + self, + *, + params: AssetOptOutParams, + ensure_zero_balance: bool = True, + ) -> SendSingleTransactionResult: + """Opt an account out of an Algorand Standard Asset.""" + if ensure_zero_balance: + try: + account_asset_info = self._asset_manager.get_account_information(params.sender, params.asset_id) + balance = account_asset_info.balance + if balance != 0: + raise ValueError( + f"Account {params.sender} does not have a zero balance for Asset " + f"{params.asset_id}; can't opt-out." + ) + except Exception as e: + raise ValueError( + f"Account {params.sender} is not opted-in to Asset {params.asset_id}; " "can't opt-out." + ) from e + + if not hasattr(params, "creator"): + asset_info = self._asset_manager.get_by_id(params.asset_id) + params = AssetOptOutParams( + **params.__dict__, + creator=asset_info.creator, + ) + + creator = params.__dict__.get("creator") + return self._send( + lambda c: c.add_asset_opt_out, + pre_log=lambda params, transaction: ( + f"Opting {params.sender} out of asset with ID {params.asset_id} to creator " + f"{creator} via transaction {transaction.get_txid()}" + ), + )(params) + + def app_create(self, params: AppCreateParams) -> SendAppCreateTransactionResult: + """Create a new application.""" + return self._send_app_create_call(lambda c: c.add_app_create)(params) + + def app_update(self, params: AppUpdateParams) -> SendAppUpdateTransactionResult: + """Update an application.""" + return self._send_app_update_call(lambda c: c.add_app_update)(params) + + def app_delete(self, params: AppDeleteParams) -> SendAppTransactionResult: + """Delete an application.""" + return self._send_app_call(lambda c: c.add_app_delete)(params) + + def app_call(self, params: AppCallParams) -> SendAppTransactionResult: + """Call an application.""" + return self._send_app_call(lambda c: c.add_app_call)(params) + + def app_create_method_call(self, params: AppCreateMethodCall) -> SendAppCreateTransactionResult: + """Call an application's create method.""" + return self._send_app_create_call(lambda c: c.add_app_create_method_call)(params) + + def app_update_method_call(self, params: AppUpdateMethodCall) -> SendAppUpdateTransactionResult: + """Call an application's update method.""" + return self._send_app_update_call(lambda c: c.add_app_update_method_call)(params) + + def app_delete_method_call(self, params: AppDeleteMethodCall) -> SendAppTransactionResult: + """Call an application's delete method.""" + return self._send_app_call(lambda c: c.add_app_delete_method_call)(params) + + def app_call_method_call(self, params: AppCallMethodCall) -> SendAppTransactionResult: + """Call an application's call method.""" + return self._send_app_call(lambda c: c.add_app_call_method_call)(params) + + def online_key_registration(self, params: OnlineKeyRegistrationParams) -> SendSingleTransactionResult: + """Register an online key.""" + return self._send( + lambda c: c.add_online_key_registration, + pre_log=lambda params, transaction: ( + f"Registering online key for {params.sender} via transaction {transaction.get_txid()}" + ), + )(params) diff --git a/tests/assets/__init__.py b/tests/assets/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/assets/test_asset_manager.py b/tests/assets/test_asset_manager.py new file mode 100644 index 00000000..61e5c255 --- /dev/null +++ b/tests/assets/test_asset_manager.py @@ -0,0 +1,207 @@ +import algosdk +import pytest +from algokit_utils import Account, get_account +from algokit_utils.assets.asset_manager import ( + AccountAssetInformation, + AssetInformation, + BulkAssetOptInOutResult, +) +from algokit_utils.clients.algorand_client import AlgorandClient +from algokit_utils.models.amount import AlgoAmount +from algokit_utils.transactions.transaction_composer import ( + AssetCreateParams, + PaymentParams, +) +from algosdk.atomic_transaction_composer import AccountTransactionSigner + +from tests.conftest import get_unique_name + + +@pytest.fixture() +def sender(funded_account: Account) -> Account: + return funded_account + + +@pytest.fixture() +def receiver(algod_client: algosdk.v2client.algod.AlgodClient) -> Account: + return get_account(algod_client, get_unique_name()) + + +@pytest.fixture() +def algorand(funded_account: Account) -> AlgorandClient: + client = AlgorandClient.default_local_net() + client.set_signer(sender=funded_account.address, signer=funded_account.signer) + return client + + +def test_get_by_id(algorand: AlgorandClient, sender: Account) -> None: + # First create an asset + total = 1000 + create_result = algorand.send.asset_create( + AssetCreateParams( + sender=sender.address, + total=total, + decimals=0, + default_frozen=False, + unit_name="TEST", + asset_name="Test Asset", + url="https://example.com", + ) + ) + asset_id = int(create_result.confirmation["asset-index"]) # type: ignore[call-overload] + + # Then get its info + asset_info = algorand.asset.get_by_id(asset_id) + + assert isinstance(asset_info, AssetInformation) + assert asset_info.asset_id == asset_id + assert asset_info.total == total + assert asset_info.decimals == 0 + assert asset_info.default_frozen is False + assert asset_info.unit_name == "TEST" + assert asset_info.asset_name == "Test Asset" + assert asset_info.url == "https://example.com" + assert asset_info.creator == sender.address + + +def test_get_account_information_with_address(algorand: AlgorandClient, sender: Account) -> None: + # First create an asset + total = 1000 + create_result = algorand.send.asset_create( + AssetCreateParams( + sender=sender.address, + total=total, + decimals=0, + default_frozen=False, + unit_name="TEST", + asset_name="Test Asset", + url="https://example.com", + ) + ) + asset_id = int(create_result.confirmation["asset-index"]) # type: ignore[call-overload] + + # Then get account info + account_info = algorand.asset.get_account_information(sender.address, asset_id) + + assert isinstance(account_info, AccountAssetInformation) + assert account_info.asset_id == asset_id + assert account_info.balance == total + assert account_info.frozen is False + + +def test_get_account_information_with_account(algorand: AlgorandClient, sender: Account) -> None: + # First create an asset + total = 1000 + create_result = algorand.send.asset_create( + AssetCreateParams( + sender=sender.address, + total=total, + decimals=0, + default_frozen=False, + unit_name="TEST", + asset_name="Test Asset", + url="https://example.com", + ) + ) + asset_id = int(create_result.confirmation["asset-index"]) # type: ignore[call-overload] + + # Then get account info + account_info = algorand.asset.get_account_information(sender, asset_id) + + assert isinstance(account_info, AccountAssetInformation) + assert account_info.asset_id == asset_id + assert account_info.balance == total + assert account_info.frozen is False + + +def test_get_account_information_with_transaction_signer(algorand: AlgorandClient, sender: Account) -> None: + # First create an asset + total = 1000 + create_result = algorand.send.asset_create( + AssetCreateParams( + sender=sender.address, + total=total, + decimals=0, + default_frozen=False, + unit_name="TEST", + asset_name="Test Asset", + url="https://example.com", + ) + ) + asset_id = int(create_result.confirmation["asset-index"]) # type: ignore[call-overload] + + # Then get account info using transaction signer + signer = AccountTransactionSigner(sender.private_key) + account_info = algorand.asset.get_account_information(signer, asset_id) + + assert isinstance(account_info, AccountAssetInformation) + assert account_info.asset_id == asset_id + assert account_info.balance == total + assert account_info.frozen is False + + +def test_bulk_opt_in_with_address(algorand: AlgorandClient, sender: Account, receiver: Account) -> None: + # First create some assets + asset_ids = [] + for i in range(3): + create_result = algorand.send.asset_create( + AssetCreateParams( + sender=sender.address, + total=1000, + decimals=0, + default_frozen=False, + unit_name=f"TST{i}", + asset_name=f"Test Asset {i}", + url="https://example.com", + signer=sender.signer, + ) + ) + asset_id = int(create_result.confirmation["asset-index"]) # type: ignore[call-overload] + asset_ids.append(asset_id) + + # Fund receiver + algorand.send.payment( + PaymentParams( + sender=sender.address, + receiver=receiver.address, + amount=AlgoAmount.from_algos(1), + ) + ) + + # Then bulk opt-in + results = algorand.asset.bulk_opt_in(receiver.address, asset_ids, signer=receiver.signer) + + assert len(results) == len(asset_ids) + for result in results: + assert isinstance(result, BulkAssetOptInOutResult) + assert result.asset_id in asset_ids + assert result.transaction_id + + +def test_bulk_opt_out_not_opted_in_fails(algorand: AlgorandClient, sender: Account, receiver: Account) -> None: + # First create an asset + create_result = algorand.send.asset_create( + AssetCreateParams( + sender=sender.address, + total=1000, + decimals=0, + default_frozen=False, + unit_name="TEST", + asset_name="Test Asset", + url="https://example.com", + ) + ) + asset_id = int(create_result.confirmation["asset-index"]) # type: ignore[call-overload] + + # Fund receiver but don't opt-in + algorand.send.payment( + PaymentParams( + sender=sender.address, + receiver=receiver.address, + amount=AlgoAmount.from_algos(1), + ) + ) + + # Then attempt to opt-out + with pytest.raises(ValueError, match="is not opted-in"): + algorand.asset.bulk_opt_out(receiver.address, [asset_id]) diff --git a/tests/test_transaction_composer.py b/tests/test_transaction_composer.py index 1cbab861..5ea937ec 100644 --- a/tests/test_transaction_composer.py +++ b/tests/test_transaction_composer.py @@ -10,11 +10,9 @@ AssetConfigParams, AssetCreateParams, PaymentParams, + SendAtomicTransactionComposerResults, TransactionComposer, ) -from algosdk.atomic_transaction_composer import ( - AtomicTransactionResponse, -) from algosdk.transaction import ( ApplicationCreateTxn, AssetConfigTxn, @@ -86,7 +84,7 @@ def test_add_asset_create(algorand: AlgorandClient, funded_account: Account) -> )["params"] assert len(response.tx_ids) == 1 - assert response.confirmed_round > 0 + assert response.confirmations[-1]["confirmed-round"] > 0 # type: ignore[call-overload] assert isinstance(built.transactions[0], AssetCreateTxn) txn = built.transactions[0] assert txn.sender == funded_account.address @@ -196,9 +194,9 @@ def test_send(algorand: AlgorandClient, funded_account: Account) -> None: ) ) response = composer.send() - assert isinstance(response, AtomicTransactionResponse) + assert isinstance(response, SendAtomicTransactionComposerResults) assert len(response.tx_ids) == 1 - assert response.confirmed_round > 0 + assert response.confirmations[-1]["confirmed-round"] > 0 # type: ignore[call-overload] def test_arc2_note() -> None: diff --git a/tests/transactions/test_transaction_composer.py b/tests/transactions/test_transaction_composer.py index 0a75961a..619668e8 100644 --- a/tests/transactions/test_transaction_composer.py +++ b/tests/transactions/test_transaction_composer.py @@ -13,11 +13,9 @@ AssetConfigParams, AssetCreateParams, PaymentParams, + SendAtomicTransactionComposerResults, TransactionComposer, ) -from algosdk.atomic_transaction_composer import ( - AtomicTransactionResponse, -) from algosdk.transaction import ( ApplicationCallTxn, ApplicationCreateTxn, @@ -90,7 +88,7 @@ def test_add_asset_create(algorand: AlgorandClient, funded_account: Account) -> )["params"] assert len(response.tx_ids) == 1 - assert response.confirmed_round > 0 + assert response.confirmations[-1]["confirmed-round"] > 0 # type: ignore[call-overload] assert isinstance(built.transactions[0], AssetCreateTxn) txn = built.transactions[0] assert txn.sender == funded_account.address @@ -207,7 +205,7 @@ def test_add_app_call_method_call(algorand: AlgorandClient, funded_account: Acco txn = built.transactions[0] assert txn.sender == funded_account.address response = composer.execute(max_rounds_to_wait=20) - assert response.abi_results[0].return_value == "Hello, world" + assert response.returns[-1] == "Hello, world" def test_simulate(algorand: AlgorandClient, funded_account: Account) -> None: @@ -240,9 +238,9 @@ def test_send(algorand: AlgorandClient, funded_account: Account) -> None: ) ) response = composer.send() - assert isinstance(response, AtomicTransactionResponse) + assert isinstance(response, SendAtomicTransactionComposerResults) assert len(response.tx_ids) == 1 - assert response.confirmed_round > 0 + assert response.confirmations[-1]["confirmed-round"] > 0 # type: ignore[call-overload] def test_arc2_note() -> None: diff --git a/tests/transactions/test_transaction_creator.py b/tests/transactions/test_transaction_creator.py new file mode 100644 index 00000000..ec84d650 --- /dev/null +++ b/tests/transactions/test_transaction_creator.py @@ -0,0 +1,267 @@ +from pathlib import Path + +import algosdk +import pytest +from algokit_utils._legacy_v2.account import get_account +from algokit_utils.clients.algorand_client import AlgorandClient +from algokit_utils.models.account import Account +from algokit_utils.models.amount import AlgoAmount +from algokit_utils.transactions.transaction_composer import ( + AppCallMethodCall, + AppCreateParams, + AssetConfigParams, + AssetCreateParams, + AssetDestroyParams, + AssetFreezeParams, + AssetOptInParams, + AssetOptOutParams, + AssetTransferParams, + OnlineKeyRegistrationParams, + PaymentParams, +) +from algosdk.transaction import ( + ApplicationCallTxn, + ApplicationCreateTxn, + AssetConfigTxn, + AssetCreateTxn, + AssetDestroyTxn, + AssetFreezeTxn, + AssetTransferTxn, + KeyregTxn, + PaymentTxn, +) + +from legacy_v2_tests.conftest import get_unique_name + + +@pytest.fixture() +def algorand(funded_account: Account) -> AlgorandClient: + client = AlgorandClient.default_local_net() + client.set_signer(sender=funded_account.address, signer=funded_account.signer) + return client + + +@pytest.fixture() +def funded_secondary_account(algorand: AlgorandClient, funded_account: Account) -> Account: + secondary_name = get_unique_name() + account = get_account(algorand.client.algod, secondary_name) + algorand.send.payment( + PaymentParams(sender=funded_account.address, receiver=account.address, amount=AlgoAmount.from_algos(1)) + ) + return account + + +def test_create_payment_transaction(algorand: AlgorandClient, funded_account: Account) -> None: + txn = algorand.create_transaction.payment( + PaymentParams( + sender=funded_account.address, + receiver=funded_account.address, + amount=AlgoAmount.from_algos(1), + ) + ) + + assert isinstance(txn, PaymentTxn) + assert txn.sender == funded_account.address + assert txn.receiver == funded_account.address + assert txn.amt == AlgoAmount.from_algos(1).micro_algos + + +def test_create_asset_create_transaction(algorand: AlgorandClient, funded_account: Account) -> None: + expected_total = 1000 + txn = algorand.create_transaction.asset_create( + AssetCreateParams( + sender=funded_account.address, + total=expected_total, + decimals=0, + default_frozen=False, + unit_name="TEST", + asset_name="Test Asset", + url="https://example.com", + ) + ) + + assert isinstance(txn, AssetCreateTxn) + assert txn.sender == funded_account.address + assert txn.total == expected_total + assert txn.decimals == 0 + assert txn.default_frozen is False + assert txn.unit_name == "TEST" + assert txn.asset_name == "Test Asset" + assert txn.url == "https://example.com" + + +def test_create_asset_config_transaction( + algorand: AlgorandClient, funded_account: Account, funded_secondary_account: Account +) -> None: + txn = algorand.create_transaction.asset_config( + AssetConfigParams( + sender=funded_account.address, + asset_id=1, + manager=funded_secondary_account.address, + ) + ) + + assert isinstance(txn, AssetConfigTxn) + assert txn.sender == funded_account.address + assert txn.index == 1 + assert txn.manager == funded_secondary_account.address + + +def test_create_asset_freeze_transaction( + algorand: AlgorandClient, funded_account: Account, funded_secondary_account: Account +) -> None: + txn = algorand.create_transaction.asset_freeze( + AssetFreezeParams( + sender=funded_account.address, + asset_id=1, + account=funded_secondary_account.address, + frozen=True, + ) + ) + + assert isinstance(txn, AssetFreezeTxn) + assert txn.sender == funded_account.address + assert txn.index == 1 + assert txn.target == funded_secondary_account.address + assert txn.new_freeze_state is True + + +def test_create_asset_destroy_transaction(algorand: AlgorandClient, funded_account: Account) -> None: + txn = algorand.create_transaction.asset_destroy( + AssetDestroyParams( + sender=funded_account.address, + asset_id=1, + ) + ) + + assert isinstance(txn, AssetDestroyTxn) + assert txn.sender == funded_account.address + assert txn.index == 1 + + +def test_create_asset_transfer_transaction( + algorand: AlgorandClient, funded_account: Account, funded_secondary_account: Account +) -> None: + expected_amount = 100 + txn = algorand.create_transaction.asset_transfer( + AssetTransferParams( + sender=funded_account.address, + asset_id=1, + amount=expected_amount, + receiver=funded_secondary_account.address, + ) + ) + + assert isinstance(txn, AssetTransferTxn) + assert txn.sender == funded_account.address + assert txn.index == 1 + assert txn.amount == expected_amount + assert txn.receiver == funded_secondary_account.address + + +def test_create_asset_opt_in_transaction(algorand: AlgorandClient, funded_account: Account) -> None: + txn = algorand.create_transaction.asset_opt_in( + AssetOptInParams( + sender=funded_account.address, + asset_id=1, + ) + ) + + assert isinstance(txn, AssetTransferTxn) + assert txn.sender == funded_account.address + assert txn.index == 1 + assert txn.amount == 0 + assert txn.receiver == funded_account.address + + +def test_create_asset_opt_out_transaction(algorand: AlgorandClient, funded_account: Account) -> None: + txn = algorand.create_transaction.asset_opt_out( + AssetOptOutParams( + sender=funded_account.address, + asset_id=1, + creator=funded_account.address, + ) + ) + + assert isinstance(txn, AssetTransferTxn) + assert txn.sender == funded_account.address + assert txn.index == 1 + assert txn.amount == 0 + assert txn.receiver == funded_account.address + assert txn.close_assets_to == funded_account.address + + +def test_create_app_create_transaction(algorand: AlgorandClient, funded_account: Account) -> None: + approval_program = "#pragma version 6\nint 1" + clear_state_program = "#pragma version 6\nint 1" + txn = algorand.create_transaction.app_create( + AppCreateParams( + sender=funded_account.address, + approval_program=approval_program, + clear_state_program=clear_state_program, + schema={"global_ints": 0, "global_bytes": 0, "local_ints": 0, "local_bytes": 0}, + ) + ) + + assert isinstance(txn, ApplicationCreateTxn) + assert txn.sender == funded_account.address + assert txn.approval_program == b"\x06\x81\x01" + assert txn.clear_program == b"\x06\x81\x01" + + +def test_create_app_call_method_call_transaction(algorand: AlgorandClient, funded_account: Account) -> None: + approval_program = Path(Path(__file__).parent / "artifacts" / "hello_world" / "approval.teal").read_text() + clear_state_program = Path(Path(__file__).parent / "artifacts" / "hello_world" / "clear.teal").read_text() + + # First create the app + create_result = algorand.send.app_create( + AppCreateParams( + sender=funded_account.address, + approval_program=approval_program, + clear_state_program=clear_state_program, + schema={"global_ints": 0, "global_bytes": 0, "local_ints": 0, "local_bytes": 0}, + ) + ) + app_id = algorand.client.algod.pending_transaction_info(create_result.tx_id)["application-index"] # type: ignore[call-overload] + + # Then test creating a method call transaction + result = algorand.create_transaction.app_call_method_call( + AppCallMethodCall( + sender=funded_account.address, + app_id=app_id, + method=algosdk.abi.Method.from_signature("hello(string)string"), + args=["world"], + ) + ) + + assert len(result.transactions) == 1 + assert isinstance(result.transactions[0], ApplicationCallTxn) + assert result.transactions[0].sender == funded_account.address + assert result.transactions[0].index == app_id + + +def test_create_online_key_registration_transaction(algorand: AlgorandClient, funded_account: Account) -> None: + sp = algorand.get_suggested_params() + expected_dilution = 100 + expected_first = sp.first + expected_last = sp.first + int(10e6) + + txn = algorand.create_transaction.online_key_registration( + OnlineKeyRegistrationParams( + sender=funded_account.address, + vote_key="G/lqTV6MKspW6J8wH2d8ZliZ5XZVZsruqSBJMwLwlmo=", + selection_key="LrpLhvzr+QpN/bivh6IPpOaKGbGzTTB5lJtVfixmmgk=", + state_proof_key=b"RpUpNWfZMjZ1zOOjv3MF2tjO714jsBt0GKnNsw0ihJ4HSZwci+d9zvUi3i67LwFUJgjQ5Dz4zZgHgGduElnmSA==", + vote_first=expected_first, + vote_last=expected_last, + vote_key_dilution=expected_dilution, + ) + ) + + assert isinstance(txn, KeyregTxn) + assert txn.sender == funded_account.address + assert txn.selkey == "LrpLhvzr+QpN/bivh6IPpOaKGbGzTTB5lJtVfixmmgk=" + assert txn.sprfkey == b"RpUpNWfZMjZ1zOOjv3MF2tjO714jsBt0GKnNsw0ihJ4HSZwci+d9zvUi3i67LwFUJgjQ5Dz4zZgHgGduElnmSA==" + assert txn.votefst == expected_first + assert txn.votelst == expected_last + assert txn.votekd == expected_dilution diff --git a/tests/transactions/test_transaction_sender.py b/tests/transactions/test_transaction_sender.py new file mode 100644 index 00000000..b8514cdb --- /dev/null +++ b/tests/transactions/test_transaction_sender.py @@ -0,0 +1,386 @@ +from typing import TYPE_CHECKING, cast +from unittest.mock import MagicMock, patch + +import pytest +from algokit_utils import ( + Account, + get_account, +) +from algokit_utils.applications.app_manager import AppManager +from algokit_utils.assets.asset_manager import AssetManager +from algokit_utils.models.amount import AlgoAmount +from algokit_utils.transactions.transaction_composer import ( + AppCreateParams, + AssetConfigParams, + AssetCreateParams, + AssetDestroyParams, + AssetFreezeParams, + AssetOptInParams, + AssetOptOutParams, + AssetTransferParams, + OnlineKeyRegistrationParams, + PaymentParams, + TransactionComposer, +) +from algokit_utils.transactions.transaction_sender import AlgorandClientTransactionSender +from algosdk.transaction import ( + ApplicationCreateTxn, + AssetConfigTxn, + AssetCreateTxn, + AssetDestroyTxn, + AssetFreezeTxn, + AssetTransferTxn, + PaymentTxn, +) + +from tests.conftest import get_unique_name + +if TYPE_CHECKING: + import algosdk + + +@pytest.fixture() +def sender(funded_account: Account) -> Account: + return funded_account + + +@pytest.fixture() +def receiver(algod_client: "algosdk.v2client.algod.AlgodClient") -> Account: + return get_account(algod_client, get_unique_name()) + + +@pytest.fixture() +def transaction_sender( + algod_client: "algosdk.v2client.algod.AlgodClient", sender: Account +) -> AlgorandClientTransactionSender: + def new_group() -> TransactionComposer: + return TransactionComposer( + algod=algod_client, + get_signer=lambda _: sender.signer, + ) + + return AlgorandClientTransactionSender( + new_group=new_group, + asset_manager=AssetManager(algod_client, new_group), + app_manager=AppManager(algod_client), + algod_client=algod_client, + ) + + +def test_payment(transaction_sender: AlgorandClientTransactionSender, sender: Account, receiver: Account) -> None: + amount = AlgoAmount.from_algos(1) + result = transaction_sender.payment( + PaymentParams( + sender=sender.address, + receiver=receiver.address, + amount=amount, + ) + ) + + assert len(result.tx_ids) == 1 + assert result.confirmations[-1]["confirmed-round"] > 0 # type: ignore[call-overload] + txn = cast(PaymentTxn, result.transaction) + assert txn.sender == sender.address + assert txn.receiver == receiver.address + assert txn.amt == amount.micro_algos + + +def test_asset_create(transaction_sender: AlgorandClientTransactionSender, sender: Account) -> None: + total = 1000 + params = AssetCreateParams( + sender=sender.address, + total=total, + decimals=0, + default_frozen=False, + unit_name="TEST", + asset_name="Test Asset", + url="https://example.com", + ) + + result = transaction_sender.asset_create(params) + assert len(result.tx_ids) == 1 + assert result.confirmations[-1]["confirmed-round"] > 0 # type: ignore[call-overload] + txn = cast(AssetCreateTxn, result.transaction) + assert txn.sender == sender.address + assert txn.total == total + assert txn.decimals == 0 + assert txn.default_frozen is False + assert txn.unit_name == "TEST" + assert txn.asset_name == "Test Asset" + assert txn.url == "https://example.com" + + +def test_asset_config(transaction_sender: AlgorandClientTransactionSender, sender: Account, receiver: Account) -> None: + # First create an asset + create_result = transaction_sender.asset_create( + AssetCreateParams( + sender=sender.address, + total=1000, + decimals=0, + default_frozen=False, + unit_name="CFG", + asset_name="Config Asset", + url="https://example.com", + manager=sender.address, + ) + ) + asset_id = int(create_result.confirmation["asset-index"]) # type: ignore[call-overload] + + # Then configure it + config_params = AssetConfigParams( + sender=sender.address, + asset_id=asset_id, + manager=receiver.address, + ) + result = transaction_sender.asset_config(config_params) + + assert len(result.tx_ids) == 1 + assert isinstance(result.transaction, AssetConfigTxn) + assert result.transaction.sender == sender.address + assert result.transaction.index == asset_id + assert result.transaction.manager == receiver.address + + +def test_asset_freeze( + transaction_sender: AlgorandClientTransactionSender, + sender: Account, +) -> None: + # First create an asset + create_result = transaction_sender.asset_create( + AssetCreateParams( + sender=sender.address, + total=1000, + decimals=0, + default_frozen=False, + unit_name="FRZ", + url="https://example.com", + asset_name="Freeze Asset", + freeze=sender.address, + manager=sender.address, + ) + ) + asset_id = int(create_result.confirmation["asset-index"]) # type: ignore[call-overload] + + # Then freeze it + freeze_params = AssetFreezeParams( + sender=sender.address, + asset_id=asset_id, + account=sender.address, + frozen=True, + ) + result = transaction_sender.asset_freeze(freeze_params) + + assert len(result.tx_ids) == 1 + txn = cast(AssetFreezeTxn, result.transaction) + assert txn.sender == sender.address + assert txn.index == asset_id + assert txn.target == sender.address + assert txn.new_freeze_state is True + + +def test_asset_destroy(transaction_sender: AlgorandClientTransactionSender, sender: Account) -> None: + # First create an asset + create_result = transaction_sender.asset_create( + AssetCreateParams( + sender=sender.address, + total=1000, + decimals=0, + default_frozen=False, + unit_name="DEL", + asset_name="Delete Asset", + manager=sender.address, + url="https://example.com", + ) + ) + asset_id = int(create_result.confirmation["asset-index"]) # type: ignore[call-overload] + + # Then destroy it + destroy_params = AssetDestroyParams( + sender=sender.address, + asset_id=asset_id, + ) + result = transaction_sender.asset_destroy(destroy_params) + + assert len(result.tx_ids) == 1 + txn = cast(AssetDestroyTxn, result.transaction) + assert txn.sender == sender.address + assert txn.index == asset_id + + +def test_asset_transfer( + transaction_sender: AlgorandClientTransactionSender, sender: Account, receiver: Account +) -> None: + # First create an asset + create_result = transaction_sender.asset_create( + AssetCreateParams( + sender=sender.address, + total=1000, + decimals=0, + default_frozen=False, + url="https://example.com", + unit_name="XFR", + asset_name="Transfer Asset", + ) + ) + asset_id = int(create_result.confirmation["asset-index"]) # type: ignore[call-overload] + + # Then opt-in receiver + transaction_sender.asset_opt_in( + AssetOptInParams( + sender=receiver.address, + asset_id=asset_id, + signer=receiver.signer, + ) + ) + + # Then transfer it + amount = 100 + transfer_params = AssetTransferParams( + sender=sender.address, + asset_id=asset_id, + receiver=receiver.address, + amount=amount, + ) + result = transaction_sender.asset_transfer(transfer_params) + + assert len(result.tx_ids) == 1 + txn = cast(AssetTransferTxn, result.transaction) + assert txn.sender == sender.address + assert txn.index == asset_id + assert txn.receiver == receiver.address + assert txn.amount == amount + + +def test_asset_opt_in(transaction_sender: AlgorandClientTransactionSender, sender: Account, receiver: Account) -> None: + # First create an asset + create_result = transaction_sender.asset_create( + AssetCreateParams( + sender=sender.address, + total=1000, + decimals=0, + default_frozen=False, + url="https://example.com", + unit_name="OPT", + asset_name="Opt Asset", + ) + ) + asset_id = int(create_result.confirmation["asset-index"]) # type: ignore[call-overload] + + # Then opt-in + opt_in_params = AssetOptInParams( + sender=receiver.address, + asset_id=asset_id, + signer=receiver.signer, + ) + result = transaction_sender.asset_opt_in(opt_in_params) + + assert len(result.tx_ids) == 1 + txn = cast(AssetTransferTxn, result.transaction) + assert txn.sender == receiver.address + assert txn.index == asset_id + assert txn.amount == 0 + assert txn.receiver == receiver.address + + +def test_asset_opt_out(transaction_sender: AlgorandClientTransactionSender, sender: Account, receiver: Account) -> None: + # First create an asset + create_result = transaction_sender.asset_create( + AssetCreateParams( + sender=sender.address, + total=1000, + decimals=0, + default_frozen=False, + url="https://example.com", + unit_name="OUT", + asset_name="Opt Out Asset", + ) + ) + asset_id = int(create_result.confirmation["asset-index"]) # type: ignore[call-overload] + + # Then opt-in + transaction_sender.asset_opt_in( + AssetOptInParams( + sender=receiver.address, + asset_id=asset_id, + signer=receiver.signer, + ) + ) + + # Then opt-out + opt_out_params = AssetOptOutParams( + sender=receiver.address, + asset_id=asset_id, + creator=sender.address, + signer=receiver.signer, + ) + result = transaction_sender.asset_opt_out(params=opt_out_params) + + txn = cast(AssetTransferTxn, result.transaction) + assert txn.sender == receiver.address + assert txn.index == asset_id + assert txn.amount == 0 + assert txn.receiver == receiver.address + assert txn.close_assets_to == sender.address + + +def test_app_create(transaction_sender: AlgorandClientTransactionSender, sender: Account) -> None: + approval_program = "#pragma version 6\nint 1" + clear_state_program = "#pragma version 6\nint 1" + params = AppCreateParams( + sender=sender.address, + approval_program=approval_program, + clear_state_program=clear_state_program, + schema={"global_ints": 0, "global_bytes": 0, "local_ints": 0, "local_bytes": 0}, + ) + + result = transaction_sender.app_create(params) + assert result.app_id > 0 + assert result.app_address + txn = cast(ApplicationCreateTxn, result.transaction) + assert txn.sender == sender.address + assert txn.approval_program == b"\x06\x81\x01" + assert txn.clear_program == b"\x06\x81\x01" + + +# TODO: add remaining app call and app method call tests + + +@patch("logging.Logger.debug") +def test_payment_logging( + mock_debug: MagicMock, + transaction_sender: AlgorandClientTransactionSender, + sender: Account, + receiver: Account, +) -> None: + amount = AlgoAmount.from_algos(1) + transaction_sender.payment( + PaymentParams( + sender=sender.address, + receiver=receiver.address, + amount=amount, + ) + ) + + assert mock_debug.call_count == 1 + log_message = mock_debug.call_args[0][0] + assert "Sending 1,000,000 µALGO" in log_message + assert sender.address in log_message + assert receiver.address in log_message + + +def test_online_key_registration(transaction_sender: AlgorandClientTransactionSender, sender: Account) -> None: + sp = transaction_sender._algod.suggested_params() # noqa: SLF001 + + params = OnlineKeyRegistrationParams( + sender=sender.address, + vote_key="G/lqTV6MKspW6J8wH2d8ZliZ5XZVZsruqSBJMwLwlmo=", + selection_key="LrpLhvzr+QpN/bivh6IPpOaKGbGzTTB5lJtVfixmmgk=", + state_proof_key=b"RpUpNWfZMjZ1zOOjv3MF2tjO714jsBt0GKnNsw0ihJ4HSZwci+d9zvUi3i67LwFUJgjQ5Dz4zZgHgGduElnmSA==", + vote_first=sp.first, + vote_last=sp.first + int(10e6), + vote_key_dilution=100, + ) + + result = transaction_sender.online_key_registration(params) + assert len(result.tx_ids) == 1 + assert result.confirmations[-1]["confirmed-round"] > 0 # type: ignore[call-overload] From 62c121a96881877c7086f9fd7708f39224ae8c08 Mon Sep 17 00:00:00 2001 From: Al Date: Wed, 11 Dec 2024 18:20:32 +0100 Subject: [PATCH 4/6] feat: AppClient, AppFactory, AppDeployer interface and various refinements on top of existing new interfaces (#124) * chore: bump ruff * chore: wip; arc32 to arc56 converter * chore: wip * chore: wip * chore: wip * chore: wip * chore: finalizing initial tests around call * chore: wip * chore: extra tests; wip * chore: adding initial logic error exposer * chore: resource population; skeletons for appdeployer and appfactory * chore: adding draft deprecation warnings; wip * chore: wip * chore: wip * chore: wip * chore: wip * chore: mypy and ruff tweaks wip * chore: make some asset param fields optional; add subtraction dunder to AlgoAmount * chore: more tests; updating deprecation decorators; initial tweaks for ruff --- .pre-commit-config.yaml | 2 + .vscode/launch.json | 16 + .vscode/settings.json | 23 +- docs/markdown/index.md | 69 +- legacy_v2_tests/conftest.py | 6 +- legacy_v2_tests/test_account.py | 1 - legacy_v2_tests/test_app.py | 1 + legacy_v2_tests/test_app_client.py | 1 + ...new_client_missing_source_map.approved.txt | 6 +- legacy_v2_tests/test_app_client_call.py | 40 +- .../test_app_client_clear_state.py | 4 +- legacy_v2_tests/test_app_client_close_out.py | 4 +- legacy_v2_tests/test_app_client_create.py | 6 +- legacy_v2_tests/test_app_client_delete.py | 4 +- legacy_v2_tests/test_app_client_deploy.py | 4 +- legacy_v2_tests/test_app_client_opt_in.py | 4 +- legacy_v2_tests/test_app_client_prepare.py | 3 +- legacy_v2_tests/test_app_client_resolve.py | 1 - .../test_app_client_signer_sender.py | 5 +- .../test_app_client_template_values.py | 2 +- legacy_v2_tests/test_app_client_update.py | 2 +- legacy_v2_tests/test_asset.py | 3 +- legacy_v2_tests/test_debug_utils.py | 16 +- legacy_v2_tests/test_deploy.py | 1 - legacy_v2_tests/test_deploy_scenarios.py | 8 +- legacy_v2_tests/test_dispenser_api_client.py | 3 +- legacy_v2_tests/test_transfer.py | 18 +- poetry.lock | 800 +++++----- pyproject.toml | 18 +- src/algokit_utils/__init__.py | 110 +- src/algokit_utils/_debugging.py | 52 +- .../_legacy_v2/_ensure_funded.py | 5 + src/algokit_utils/_legacy_v2/_transfer.py | 2 +- src/algokit_utils/_legacy_v2/account.py | 27 +- .../_legacy_v2/application_client.py | 23 +- .../_legacy_v2/application_specification.py | 11 +- src/algokit_utils/_legacy_v2/asset.py | 9 + src/algokit_utils/_legacy_v2/deploy.py | 30 +- src/algokit_utils/_legacy_v2/logic_error.py | 11 +- src/algokit_utils/_legacy_v2/models.py | 8 +- .../_legacy_v2/network_clients.py | 14 +- src/algokit_utils/accounts/account_manager.py | 463 +++++- .../accounts/kmd_account_manager.py | 190 +++ src/algokit_utils/applications/app_client.py | 1396 +++++++++++++++++ .../applications/app_deployer.py | 602 +++++++ src/algokit_utils/applications/app_factory.py | 690 ++++++++ src/algokit_utils/applications/app_manager.py | 119 +- src/algokit_utils/applications/utils.py | 428 +++++ src/algokit_utils/assets/asset_manager.py | 6 +- src/algokit_utils/clients/algorand_client.py | 57 +- src/algokit_utils/clients/client_manager.py | 249 ++- .../clients/dispenser_api_client.py | 5 +- src/algokit_utils/config.py | 60 +- src/algokit_utils/errors/logic_error.py | 129 ++ src/algokit_utils/models/abi.py | 12 +- src/algokit_utils/models/account.py | 4 +- src/algokit_utils/models/amount.py | 24 + src/algokit_utils/models/application.py | 464 ++++++ src/algokit_utils/models/network.py | 20 + src/algokit_utils/models/transaction.py | 8 + src/algokit_utils/protocols/__init__.py | 0 src/algokit_utils/protocols/application.py | 61 + src/algokit_utils/transactions/models.py | 52 +- .../transactions/transaction_composer.py | 443 +++--- .../transactions/transaction_sender.py | 51 +- src/algokit_utils/transactions/utils.py | 302 ++++ tests/accounts/__init__.py | 0 tests/accounts/test_account_manager.py | 108 ++ tests/applications/test_app_client.py | 733 +++++++++ tests/applications/test_app_factory.py | 488 ++++++ tests/applications/test_app_manager.py | 22 +- tests/applications/test_utils.py | 16 + .../artifacts/hello_world/approval.teal | 0 .../artifacts/hello_world/arc32_app_spec.json | 55 + .../artifacts/hello_world/clear.teal | 0 .../legacy_hello_world/arc32_app_spec.json | 378 +++++ .../artifacts/testing_app/arc32_app_spec.json | 400 +++++ tests/artifacts/testing_app/contract.py | 185 +++ .../testing_app/sources.teal.map.json | 22 + .../testing_app_arc56/arc56_app_spec.json | 681 ++++++++ .../testing_app_puya/arc32_app_spec.json | 184 +++ tests/artifacts/testing_app_puya/contract.py | 43 + tests/assets/test_asset_manager.py | 39 +- tests/clients/algorand_client/__init__.py | 0 .../clients/algorand_client/test_transfer.py | 427 +++++ tests/clients/test_algorand_client.py | 223 --- tests/conftest.py | 93 +- tests/test_transaction_composer.py | 35 +- .../transactions/test_transaction_composer.py | 53 +- .../transactions/test_transaction_creator.py | 51 +- tests/transactions/test_transaction_sender.py | 163 +- tests/utils.py | 29 + 92 files changed, 10226 insertions(+), 1410 deletions(-) create mode 100644 .vscode/launch.json create mode 100644 src/algokit_utils/accounts/kmd_account_manager.py create mode 100644 src/algokit_utils/applications/app_client.py create mode 100644 src/algokit_utils/applications/app_deployer.py create mode 100644 src/algokit_utils/applications/app_factory.py create mode 100644 src/algokit_utils/applications/utils.py create mode 100644 src/algokit_utils/errors/logic_error.py create mode 100644 src/algokit_utils/models/network.py create mode 100644 src/algokit_utils/models/transaction.py create mode 100644 src/algokit_utils/protocols/__init__.py create mode 100644 src/algokit_utils/protocols/application.py create mode 100644 src/algokit_utils/transactions/utils.py create mode 100644 tests/accounts/__init__.py create mode 100644 tests/accounts/test_account_manager.py create mode 100644 tests/applications/test_app_client.py create mode 100644 tests/applications/test_app_factory.py create mode 100644 tests/applications/test_utils.py rename tests/{transactions => }/artifacts/hello_world/approval.teal (100%) create mode 100644 tests/artifacts/hello_world/arc32_app_spec.json rename tests/{transactions => }/artifacts/hello_world/clear.teal (100%) create mode 100644 tests/artifacts/legacy_hello_world/arc32_app_spec.json create mode 100644 tests/artifacts/testing_app/arc32_app_spec.json create mode 100644 tests/artifacts/testing_app/contract.py create mode 100644 tests/artifacts/testing_app/sources.teal.map.json create mode 100644 tests/artifacts/testing_app_arc56/arc56_app_spec.json create mode 100644 tests/artifacts/testing_app_puya/arc32_app_spec.json create mode 100644 tests/artifacts/testing_app_puya/contract.py create mode 100644 tests/clients/algorand_client/__init__.py create mode 100644 tests/clients/algorand_client/test_transfer.py delete mode 100644 tests/clients/test_algorand_client.py create mode 100644 tests/utils.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 10c320aa..fdfd6d3f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -23,6 +23,7 @@ repos: additional_dependencies: [] minimum_pre_commit_version: "0" files: "^(src|tests)/" + exclude: "^tests/artifacts/" - id: mypy name: mypy description: "`mypy` will check Python types for correctness" @@ -33,3 +34,4 @@ repos: additional_dependencies: [] minimum_pre_commit_version: "2.9.2" files: "^(src|tests)/" + exclude: "^tests/artifacts/" diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..6b9d5948 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,16 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Python: Debug Tests", + "type": "debugpy", + "request": "launch", + "program": "${file}", + "purpose": [ + "debug-test" + ], + "console": "integratedTerminal", + "justMyCode": false + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json index e570b2a6..a1162966 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -16,18 +16,24 @@ "**/__pycache__": true, ".idea": true }, - // Python "platformSettings.autoLoad": true, "python.defaultInterpreterPath": "${workspaceFolder}/.venv", - "python.analysis.extraPaths": ["${workspaceFolder}/src"], + "python.analysis.extraPaths": [ + "${workspaceFolder}/src" + ], "[python]": { "editor.defaultFormatter": "charliermarsh.ruff" }, + "python.analysis.exclude": [ + "tests/artifacts/**" + ], "python.analysis.typeCheckingMode": "basic", "ruff.enable": true, "ruff.lint.run": "onSave", - "ruff.lint.args": ["--config=pyproject.toml"], + "ruff.lint.args": [ + "--config=pyproject.toml" + ], "ruff.importStrategy": "fromEnvironment", "ruff.fixAll": true, //lint and fix all files in workspace "ruff.organizeImports": true, //organize imports on save @@ -37,7 +43,6 @@ "ruff.codeAction.fixViolation": { "enable": true }, - "mypy.configFile": "pyproject.toml", // set to empty array to use config from project "mypy.targets": [], @@ -52,11 +57,7 @@ } ] }, - - // PowerShell - "[powershell]": { - "editor.defaultFormatter": "ms-vscode.powershell" - }, - "powershell.codeFormatting.preset": "Stroustrup", - "python.testing.pytestArgs": ["."] + "python.testing.pytestArgs": [ + "." + ], } diff --git a/docs/markdown/index.md b/docs/markdown/index.md index a3fa0518..71972566 100644 --- a/docs/markdown/index.md +++ b/docs/markdown/index.md @@ -7,46 +7,47 @@ The goal of this library is to provide intuitive, productive utility functions t Largely these functions wrap the underlying Algorand SDK, but provide a higher level interface with sensible defaults and capabilities for common tasks. #### NOTE + If you prefer TypeScript there’s an equivalent [TypeScript utility library](https://github.com/algorandfoundation/algokit-utils-ts). [Core principles]() | [Installation]() | [Usage]() | [Capabilities]() | [Reference docs]() # Contents -* [Account management](capabilities/account.md) - * [`Account`](capabilities/account.md#account) -* [Client management](capabilities/client.md) - * [Network configuration](capabilities/client.md#network-configuration) - * [Clients](capabilities/client.md#clients) -* [App client](capabilities/app-client.md) - * [Design](capabilities/app-client.md#design) - * [Creating an application client](capabilities/app-client.md#creating-an-application-client) - * [Calling methods on the app](capabilities/app-client.md#calling-methods-on-the-app) - * [Composing calls](capabilities/app-client.md#composing-calls) - * [Reading state](capabilities/app-client.md#reading-state) - * [Handling logic errors and diagnosing errors](capabilities/app-client.md#handling-logic-errors-and-diagnosing-errors) -* [App deployment](capabilities/app-deploy.md) - * [Design](capabilities/app-deploy.md#design) - * [Finding apps by creator](capabilities/app-deploy.md#finding-apps-by-creator) - * [Deploying an application](capabilities/app-deploy.md#deploying-an-application) -* [Algo transfers](capabilities/transfer.md) - * [Transferring Algos](capabilities/transfer.md#transferring-algos) - * [Ensuring minimum Algos](capabilities/transfer.md#ensuring-minimum-algos) - * [Transfering Assets](capabilities/transfer.md#transfering-assets) - * [Dispenser](capabilities/transfer.md#dispenser) -* [TestNet Dispenser Client](capabilities/dispenser-client.md) - * [Creating a Dispenser Client](capabilities/dispenser-client.md#creating-a-dispenser-client) - * [Funding an Account](capabilities/dispenser-client.md#funding-an-account) - * [Registering a Refund](capabilities/dispenser-client.md#registering-a-refund) - * [Getting Current Limit](capabilities/dispenser-client.md#getting-current-limit) - * [Error Handling](capabilities/dispenser-client.md#error-handling) -* [Debugger](capabilities/debugger.md) - * [Configuration](capabilities/debugger.md#configuration) - * [Debugging Utilities](capabilities/debugger.md#debugging-utilities) -* [`algokit_utils`](apidocs/algokit_utils/algokit_utils.md) - * [Data](apidocs/algokit_utils/algokit_utils.md#data) - * [Classes](apidocs/algokit_utils/algokit_utils.md#classes) - * [Functions](apidocs/algokit_utils/algokit_utils.md#functions) +- [Account management](capabilities/account.md) + - [`Account`](capabilities/account.md#account) +- [Client management](capabilities/client.md) + - [Network configuration](capabilities/client.md#network-configuration) + - [Clients](capabilities/client.md#clients) +- [App client](capabilities/app-client.md) + - [Design](capabilities/app-client.md#design) + - [Creating an application client](capabilities/app-client.md#creating-an-application-client) + - [Calling methods on the app](capabilities/app-client.md#calling-methods-on-the-app) + - [Composing calls](capabilities/app-client.md#composing-calls) + - [Reading state](capabilities/app-client.md#reading-state) + - [Handling logic errors and diagnosing errors](capabilities/app-client.md#handling-logic-errors-and-diagnosing-errors) +- [App deployment](capabilities/app-deploy.md) + - [Design](capabilities/app-deploy.md#design) + - [Finding apps by creator](capabilities/app-deploy.md#finding-apps-by-creator) + - [Deploying an application](capabilities/app-deploy.md#deploying-an-application) +- [Algo transfers](capabilities/transfer.md) + - [Transferring Algos](capabilities/transfer.md#transferring-algos) + - [Ensuring minimum Algos](capabilities/transfer.md#ensuring-minimum-algos) + - [Transfering Assets](capabilities/transfer.md#transfering-assets) + - [Dispenser](capabilities/transfer.md#dispenser) +- [TestNet Dispenser Client](capabilities/dispenser-client.md) + - [Creating a Dispenser Client](capabilities/dispenser-client.md#creating-a-dispenser-client) + - [Funding an Account](capabilities/dispenser-client.md#funding-an-account) + - [Registering a Refund](capabilities/dispenser-client.md#registering-a-refund) + - [Getting Current Limit](capabilities/dispenser-client.md#getting-current-limit) + - [Error Handling](capabilities/dispenser-client.md#error-handling) +- [Debugger](capabilities/debugger.md) + - [Configuration](capabilities/debugger.md#configuration) + - [Debugging Utilities](capabilities/debugger.md#debugging-utilities) +- [`algokit_utils`](apidocs/algokit_utils/algokit_utils.md) + - [Data](apidocs/algokit_utils/algokit_utils.md#data) + - [Classes](apidocs/algokit_utils/algokit_utils.md#classes) + - [Functions](apidocs/algokit_utils/algokit_utils.md#functions) diff --git a/legacy_v2_tests/conftest.py b/legacy_v2_tests/conftest.py index dbe4be46..f8989eb8 100644 --- a/legacy_v2_tests/conftest.py +++ b/legacy_v2_tests/conftest.py @@ -8,6 +8,8 @@ import algosdk.transaction import pytest +from dotenv import load_dotenv + from algokit_utils import ( DELETABLE_TEMPLATE_NAME, UPDATABLE_TEMPLATE_NAME, @@ -22,8 +24,6 @@ get_kmd_client_from_algod_client, replace_template_variables, ) -from dotenv import load_dotenv - from legacy_v2_tests import app_client_test if TYPE_CHECKING: @@ -142,7 +142,7 @@ def indexer_client() -> "IndexerClient": return get_indexer_client() -@pytest.fixture() +@pytest.fixture def creator(algod_client: "AlgodClient") -> Account: creator_name = get_unique_name() return get_account(algod_client, creator_name) diff --git a/legacy_v2_tests/test_account.py b/legacy_v2_tests/test_account.py index bb0ee272..e1ee2228 100644 --- a/legacy_v2_tests/test_account.py +++ b/legacy_v2_tests/test_account.py @@ -1,7 +1,6 @@ from typing import TYPE_CHECKING from algokit_utils import get_account - from legacy_v2_tests.conftest import get_unique_name if TYPE_CHECKING: diff --git a/legacy_v2_tests/test_app.py b/legacy_v2_tests/test_app.py index 07e258a1..1b79f708 100644 --- a/legacy_v2_tests/test_app.py +++ b/legacy_v2_tests/test_app.py @@ -1,4 +1,5 @@ import pytest + from algokit_utils import AppDeployMetaData diff --git a/legacy_v2_tests/test_app_client.py b/legacy_v2_tests/test_app_client.py index 87826175..b6565148 100644 --- a/legacy_v2_tests/test_app_client.py +++ b/legacy_v2_tests/test_app_client.py @@ -1,4 +1,5 @@ import pytest + from algokit_utils import ( DeploymentFailedError, get_next_version, diff --git a/legacy_v2_tests/test_app_client_call.approvals/test_readonly_call_with_error_with_new_client_missing_source_map.approved.txt b/legacy_v2_tests/test_app_client_call.approvals/test_readonly_call_with_error_with_new_client_missing_source_map.approved.txt index 598d4c2f..70d16cc9 100644 --- a/legacy_v2_tests/test_app_client_call.approvals/test_readonly_call_with_error_with_new_client_missing_source_map.approved.txt +++ b/legacy_v2_tests/test_app_client_call.approvals/test_readonly_call_with_error_with_new_client_missing_source_map.approved.txt @@ -2,6 +2,6 @@ Txn {txn} had error 'assert failed pc=743' at PC 743: Could not determine TEAL source line for the error as no approval source map was provided, to receive a trace of the error please provide an approval SourceMap. Either by: - 1.) Providing template_values when creating the ApplicationClient, so a SourceMap can be obtained automatically OR - 2.) Set approval_source_map from a previously compiled approval program OR - 3.) Import a previously exported source map using import_source_map \ No newline at end of file + 1. Providing template_values when creating the ApplicationClient, so a SourceMap can be obtained automatically OR + 2. Set approval_source_map from a previously compiled approval program OR + 3. Import a previously exported source map using import_source_map \ No newline at end of file diff --git a/legacy_v2_tests/test_app_client_call.py b/legacy_v2_tests/test_app_client_call.py index 67acd4d5..14933f1b 100644 --- a/legacy_v2_tests/test_app_client_call.py +++ b/legacy_v2_tests/test_app_client_call.py @@ -1,17 +1,10 @@ from collections.abc import Generator +from hashlib import sha256 from pathlib import Path from typing import TYPE_CHECKING from unittest.mock import Mock, patch -import algokit_utils import pytest -from algokit_utils import ( - Account, - ApplicationClient, - ApplicationSpecification, - CreateCallParameters, - get_account, -) from algosdk.atomic_transaction_composer import ( AccountTransactionSigner, AtomicTransactionComposer, @@ -19,6 +12,16 @@ ) from algosdk.transaction import ApplicationCallTxn, PaymentTxn +import algokit_utils +import algokit_utils._legacy_v2 +import algokit_utils._legacy_v2.logic_error +from algokit_utils import ( + Account, + ApplicationClient, + ApplicationSpecification, + CreateCallParameters, + get_account, +) from legacy_v2_tests.conftest import check_output_stability, get_unique_name if TYPE_CHECKING: @@ -84,7 +87,7 @@ def test_abi_call_with_transaction_arg(client_fixture: ApplicationClient, funded sender=funded_account.address, receiver=client_fixture.app_address, amt=1_000_000, - note=b"Payment", + note=sha256(b"self-payment").digest(), sp=client_fixture.algod_client.suggested_params(), ) # type: ignore[no-untyped-call] payment_with_signer = TransactionWithSigner(payment, AccountTransactionSigner(funded_account.private_key)) @@ -186,7 +189,7 @@ def test_readonly_call(client_fixture: ApplicationClient) -> None: def test_readonly_call_with_error(client_fixture: ApplicationClient) -> None: - with pytest.raises(algokit_utils.LogicError) as ex: + with pytest.raises(algokit_utils._legacy_v2.logic_error.LogicError) as ex: # noqa: SLF001 client_fixture.call( "readonly", error=1, @@ -211,7 +214,7 @@ def test_readonly_call_with_error_with_new_client_provided_template_values( ) new_client.approval_source_map = client.approval_source_map - with pytest.raises(algokit_utils.LogicError) as ex: + with pytest.raises(algokit_utils._legacy_v2.logic_error.LogicError) as ex: # noqa: SLF001 new_client.call( "readonly", error=1, @@ -234,7 +237,7 @@ def test_readonly_call_with_error_with_new_client_provided_source_map( new_client = ApplicationClient(algod_client, app_spec, app_id=client.app_id, signer=funded_account) new_client.approval_source_map = client.approval_source_map - with pytest.raises(algokit_utils.LogicError) as ex: + with pytest.raises(algokit_utils._legacy_v2.logic_error.LogicError) as ex: # noqa: SLF001 new_client.call( "readonly", error=1, @@ -259,7 +262,7 @@ def test_readonly_call_with_error_with_imported_source_map( new_client = ApplicationClient(algod_client, app_spec, app_id=client.app_id, signer=funded_account) new_client.import_source_map(source_map_export) - with pytest.raises(algokit_utils.LogicError) as ex: + with pytest.raises(algokit_utils._legacy_v2.logic_error.LogicError) as ex: # noqa: SLF001 new_client.call( "readonly", error=1, @@ -281,7 +284,7 @@ def test_readonly_call_with_error_with_new_client_missing_source_map( new_client = ApplicationClient(algod_client, app_spec, app_id=client.app_id, signer=funded_account) - with pytest.raises(algokit_utils.LogicError) as ex: + with pytest.raises(algokit_utils._legacy_v2.logic_error.LogicError) as ex: # noqa: SLF001 new_client.call( "readonly", error=1, @@ -292,7 +295,7 @@ def test_readonly_call_with_error_with_new_client_missing_source_map( def test_readonly_call_with_error_debug_mode_disabled(mock_config: Mock, client_fixture: ApplicationClient) -> None: mock_config.debug = False - with pytest.raises(algokit_utils.LogicError) as ex: + with pytest.raises(algokit_utils._legacy_v2.logic_error.LogicError) as ex: # noqa: SLF001 client_fixture.call( "readonly", error=1, @@ -302,7 +305,7 @@ def test_readonly_call_with_error_debug_mode_disabled(mock_config: Mock, client_ def test_readonly_call_with_error_debug_mode_enabled(client_fixture: ApplicationClient) -> None: - with pytest.raises(algokit_utils.LogicError) as ex: + with pytest.raises(algokit_utils._legacy_v2.logic_error.LogicError) as ex: # noqa: SLF001 client_fixture.call( "readonly", error=1, @@ -322,7 +325,7 @@ def test_app_call_with_error_debug_mode_disabled(mock_config: Mock, client_fixtu min_funding_increment_micro_algos=200_000, ), ) - with pytest.raises(algokit_utils.LogicError) as ex: + with pytest.raises(algokit_utils._legacy_v2.logic_error.LogicError) as ex: # noqa: SLF001 client_fixture.call( "set_box", name=b"ssss", @@ -342,7 +345,7 @@ def test_app_call_with_error_debug_mode_enabled(client_fixture: ApplicationClien min_funding_increment_micro_algos=200_000, ), ) - with pytest.raises(algokit_utils.LogicError) as ex: + with pytest.raises(algokit_utils._legacy_v2.logic_error.LogicError) as ex: # noqa: SLF001 client_fixture.call( "set_box", name=b"ssss", @@ -350,4 +353,3 @@ def test_app_call_with_error_debug_mode_enabled(client_fixture: ApplicationClien ) assert ex.value.traces is not None - assert ex.value.traces[0].exec_trace["approval-program-trace"] is not None diff --git a/legacy_v2_tests/test_app_client_clear_state.py b/legacy_v2_tests/test_app_client_clear_state.py index f26a7094..1d2f6529 100644 --- a/legacy_v2_tests/test_app_client_clear_state.py +++ b/legacy_v2_tests/test_app_client_clear_state.py @@ -2,12 +2,12 @@ from typing import TYPE_CHECKING import pytest + from algokit_utils import ( Account, ApplicationClient, ApplicationSpecification, ) - from legacy_v2_tests.conftest import is_opted_in if TYPE_CHECKING: @@ -15,7 +15,7 @@ from algosdk.v2client.indexer import IndexerClient -@pytest.fixture() +@pytest.fixture def client_fixture( algod_client: "AlgodClient", indexer_client: "IndexerClient", diff --git a/legacy_v2_tests/test_app_client_close_out.py b/legacy_v2_tests/test_app_client_close_out.py index 5ee5e9c6..81ac5ea9 100644 --- a/legacy_v2_tests/test_app_client_close_out.py +++ b/legacy_v2_tests/test_app_client_close_out.py @@ -1,13 +1,13 @@ from typing import TYPE_CHECKING import pytest + from algokit_utils import ( Account, ApplicationClient, ApplicationSpecification, LogicError, ) - from legacy_v2_tests.conftest import check_output_stability, is_opted_in if TYPE_CHECKING: @@ -15,7 +15,7 @@ from algosdk.v2client.indexer import IndexerClient -@pytest.fixture() +@pytest.fixture def client_fixture( algod_client: "AlgodClient", indexer_client: "IndexerClient", diff --git a/legacy_v2_tests/test_app_client_create.py b/legacy_v2_tests/test_app_client_create.py index 1da7bbf7..00fd9691 100644 --- a/legacy_v2_tests/test_app_client_create.py +++ b/legacy_v2_tests/test_app_client_create.py @@ -2,6 +2,9 @@ from typing import TYPE_CHECKING import pytest +from algosdk.atomic_transaction_composer import AccountTransactionSigner, AtomicTransactionComposer, TransactionSigner +from algosdk.transaction import ApplicationCallTxn, GenericSignedTransaction, OnComplete, Transaction + from algokit_utils import ( Account, ApplicationClient, @@ -10,9 +13,6 @@ get_account, get_app_id_from_tx_id, ) -from algosdk.atomic_transaction_composer import AccountTransactionSigner, AtomicTransactionComposer, TransactionSigner -from algosdk.transaction import ApplicationCallTxn, GenericSignedTransaction, OnComplete, Transaction - from legacy_v2_tests.conftest import check_output_stability, get_unique_name if TYPE_CHECKING: diff --git a/legacy_v2_tests/test_app_client_delete.py b/legacy_v2_tests/test_app_client_delete.py index 353bbfab..d5df42cb 100644 --- a/legacy_v2_tests/test_app_client_delete.py +++ b/legacy_v2_tests/test_app_client_delete.py @@ -1,13 +1,13 @@ from typing import TYPE_CHECKING import pytest + from algokit_utils import ( Account, ApplicationClient, ApplicationSpecification, LogicError, ) - from legacy_v2_tests.conftest import check_output_stability if TYPE_CHECKING: @@ -15,7 +15,7 @@ from algosdk.v2client.indexer import IndexerClient -@pytest.fixture() +@pytest.fixture def client_fixture( algod_client: "AlgodClient", indexer_client: "IndexerClient", diff --git a/legacy_v2_tests/test_app_client_deploy.py b/legacy_v2_tests/test_app_client_deploy.py index 4eed49b6..e51392b4 100644 --- a/legacy_v2_tests/test_app_client_deploy.py +++ b/legacy_v2_tests/test_app_client_deploy.py @@ -1,6 +1,7 @@ from typing import TYPE_CHECKING import pytest + from algokit_utils import ( ABICreateCallArgs, Account, @@ -9,7 +10,6 @@ TransferParameters, transfer, ) - from legacy_v2_tests.conftest import get_unique_name, read_spec if TYPE_CHECKING: @@ -17,7 +17,7 @@ from algosdk.v2client.indexer import IndexerClient -@pytest.fixture() +@pytest.fixture def client_fixture( algod_client: "AlgodClient", indexer_client: "IndexerClient", diff --git a/legacy_v2_tests/test_app_client_opt_in.py b/legacy_v2_tests/test_app_client_opt_in.py index 816e96f0..afc1fb1e 100644 --- a/legacy_v2_tests/test_app_client_opt_in.py +++ b/legacy_v2_tests/test_app_client_opt_in.py @@ -1,13 +1,13 @@ from typing import TYPE_CHECKING import pytest + from algokit_utils import ( Account, ApplicationClient, ApplicationSpecification, LogicError, ) - from legacy_v2_tests.conftest import check_output_stability, is_opted_in if TYPE_CHECKING: @@ -15,7 +15,7 @@ from algosdk.v2client.indexer import IndexerClient -@pytest.fixture() +@pytest.fixture def client_fixture( algod_client: "AlgodClient", indexer_client: "IndexerClient", diff --git a/legacy_v2_tests/test_app_client_prepare.py b/legacy_v2_tests/test_app_client_prepare.py index 6c6355b0..affacd50 100644 --- a/legacy_v2_tests/test_app_client_prepare.py +++ b/legacy_v2_tests/test_app_client_prepare.py @@ -1,11 +1,12 @@ import base64 from typing import TYPE_CHECKING +from algosdk.atomic_transaction_composer import AccountTransactionSigner + from algokit_utils import ( ApplicationClient, ApplicationSpecification, ) -from algosdk.atomic_transaction_composer import AccountTransactionSigner if TYPE_CHECKING: from algosdk.v2client.algod import AlgodClient diff --git a/legacy_v2_tests/test_app_client_resolve.py b/legacy_v2_tests/test_app_client_resolve.py index 6c6023f3..d7e8b1d1 100644 --- a/legacy_v2_tests/test_app_client_resolve.py +++ b/legacy_v2_tests/test_app_client_resolve.py @@ -5,7 +5,6 @@ ApplicationClient, DefaultArgumentDict, ) - from legacy_v2_tests.conftest import read_spec if TYPE_CHECKING: diff --git a/legacy_v2_tests/test_app_client_signer_sender.py b/legacy_v2_tests/test_app_client_signer_sender.py index d6c383cb..cfdef0ac 100644 --- a/legacy_v2_tests/test_app_client_signer_sender.py +++ b/legacy_v2_tests/test_app_client_signer_sender.py @@ -3,12 +3,13 @@ from typing import TYPE_CHECKING, Any import pytest +from algosdk.atomic_transaction_composer import AccountTransactionSigner, TransactionSigner + from algokit_utils import ( ApplicationClient, ApplicationSpecification, get_sender_from_signer, ) -from algosdk.atomic_transaction_composer import AccountTransactionSigner, TransactionSigner if TYPE_CHECKING: from algosdk import transaction @@ -30,7 +31,7 @@ def sign_transactions( @pytest.mark.parametrize("override_signer", [CustomSigner(), AccountTransactionSigner(fake_key), None]) @pytest.mark.parametrize("default_sender", ["default_sender", None]) @pytest.mark.parametrize("default_signer", [CustomSigner(), AccountTransactionSigner(fake_key), None]) -def test_resolve_signer_sender( # noqa: PLR0913 +def test_resolve_signer_sender( *, algod_client: "AlgodClient", app_spec: ApplicationSpecification, diff --git a/legacy_v2_tests/test_app_client_template_values.py b/legacy_v2_tests/test_app_client_template_values.py index 5b27f320..a01f53d9 100644 --- a/legacy_v2_tests/test_app_client_template_values.py +++ b/legacy_v2_tests/test_app_client_template_values.py @@ -1,8 +1,8 @@ from typing import TYPE_CHECKING -import algokit_utils import pytest +import algokit_utils from legacy_v2_tests.conftest import get_unique_name, read_spec if TYPE_CHECKING: diff --git a/legacy_v2_tests/test_app_client_update.py b/legacy_v2_tests/test_app_client_update.py index 60cd10d9..4dc082e0 100644 --- a/legacy_v2_tests/test_app_client_update.py +++ b/legacy_v2_tests/test_app_client_update.py @@ -1,13 +1,13 @@ from typing import TYPE_CHECKING import pytest + from algokit_utils import ( Account, ApplicationClient, ApplicationSpecification, LogicError, ) - from legacy_v2_tests.conftest import check_output_stability if TYPE_CHECKING: diff --git a/legacy_v2_tests/test_asset.py b/legacy_v2_tests/test_asset.py index 3d75fa86..c26906ff 100644 --- a/legacy_v2_tests/test_asset.py +++ b/legacy_v2_tests/test_asset.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING import pytest + from algokit_utils import ( Account, EnsureBalanceParameters, @@ -19,7 +20,7 @@ from legacy_v2_tests.conftest import assure_funds, generate_test_asset, get_unique_name -@pytest.fixture() +@pytest.fixture def to_account(kmd_client: "KMDClient") -> Account: return create_kmd_wallet_account(kmd_client, get_unique_name()) diff --git a/legacy_v2_tests/test_debug_utils.py b/legacy_v2_tests/test_debug_utils.py index 9b6d8ca8..b827ecd3 100644 --- a/legacy_v2_tests/test_debug_utils.py +++ b/legacy_v2_tests/test_debug_utils.py @@ -3,6 +3,13 @@ from unittest.mock import Mock import pytest +from algosdk.atomic_transaction_composer import ( + AccountTransactionSigner, + AtomicTransactionComposer, + TransactionWithSigner, +) +from algosdk.transaction import PaymentTxn + from algokit_utils._debugging import ( AVMDebuggerSourceMap, PersistSourceMapInput, @@ -14,20 +21,13 @@ from algokit_utils.application_specification import ApplicationSpecification from algokit_utils.common import Program from algokit_utils.models import Account -from algosdk.atomic_transaction_composer import ( - AccountTransactionSigner, - AtomicTransactionComposer, - TransactionWithSigner, -) -from algosdk.transaction import PaymentTxn - from legacy_v2_tests.conftest import check_output_stability, get_unique_name if TYPE_CHECKING: from algosdk.v2client.algod import AlgodClient -@pytest.fixture() +@pytest.fixture def client_fixture(algod_client: "AlgodClient", app_spec: ApplicationSpecification) -> ApplicationClient: creator_name = get_unique_name() creator = get_account(algod_client, creator_name) diff --git a/legacy_v2_tests/test_deploy.py b/legacy_v2_tests/test_deploy.py index 51708f52..4d2cf8c0 100644 --- a/legacy_v2_tests/test_deploy.py +++ b/legacy_v2_tests/test_deploy.py @@ -2,7 +2,6 @@ replace_template_variables, ) from algokit_utils._legacy_v2.deploy import strip_comments - from legacy_v2_tests.conftest import check_output_stability diff --git a/legacy_v2_tests/test_deploy_scenarios.py b/legacy_v2_tests/test_deploy_scenarios.py index 309fe4a3..c230ce37 100644 --- a/legacy_v2_tests/test_deploy_scenarios.py +++ b/legacy_v2_tests/test_deploy_scenarios.py @@ -6,6 +6,7 @@ from unittest.mock import Mock, patch import pytest + from algokit_utils import ( Account, ApplicationClient, @@ -19,7 +20,6 @@ get_indexer_client, get_localnet_default_account, ) - from legacy_v2_tests.conftest import check_output_stability, get_specs, get_unique_name, read_spec logger = logging.getLogger(__name__) @@ -56,7 +56,7 @@ def __init__( self.creator = creator self.app_name = get_unique_name() - def deploy( # noqa: PLR0913 + def deploy( self, app_spec: ApplicationSpecification, *, @@ -128,12 +128,12 @@ def creator(creator_name: str) -> Account: return get_account(get_algod_client(), creator_name) -@pytest.fixture() +@pytest.fixture def app_name() -> str: return get_unique_name() -@pytest.fixture() +@pytest.fixture def deploy_fixture( caplog: pytest.LogCaptureFixture, request: pytest.FixtureRequest, creator_name: str, creator: Account ) -> DeployFixture: diff --git a/legacy_v2_tests/test_dispenser_api_client.py b/legacy_v2_tests/test_dispenser_api_client.py index baa2e1db..ac7fa0f8 100644 --- a/legacy_v2_tests/test_dispenser_api_client.py +++ b/legacy_v2_tests/test_dispenser_api_client.py @@ -1,13 +1,14 @@ import json import pytest +from pytest_httpx import HTTPXMock + from algokit_utils.dispenser_api import ( DISPENSER_ASSETS, DispenserApiConfig, DispenserAssetName, TestNetDispenserApiClient, ) -from pytest_httpx import HTTPXMock class TestDispenserApiTestnetClient: diff --git a/legacy_v2_tests/test_transfer.py b/legacy_v2_tests/test_transfer.py index 8253a5eb..335fcf1a 100644 --- a/legacy_v2_tests/test_transfer.py +++ b/legacy_v2_tests/test_transfer.py @@ -3,6 +3,11 @@ import algosdk import httpx import pytest +from algosdk.atomic_transaction_composer import AccountTransactionSigner +from algosdk.transaction import PaymentTxn +from algosdk.util import algos_to_microalgos +from pytest_httpx import HTTPXMock + from algokit_utils import ( Account, EnsureBalanceParameters, @@ -19,11 +24,6 @@ ) from algokit_utils.dispenser_api import DispenserApiConfig from algokit_utils.network_clients import get_algod_client, get_algonode_config -from algosdk.atomic_transaction_composer import AccountTransactionSigner -from algosdk.transaction import PaymentTxn -from algosdk.util import algos_to_microalgos -from pytest_httpx import HTTPXMock - from legacy_v2_tests.conftest import assure_funds, check_output_stability, generate_test_asset, get_unique_name from legacy_v2_tests.test_network_clients import DEFAULT_TOKEN @@ -35,12 +35,12 @@ MINIMUM_BALANCE = 100_000 # see https://developer.algorand.org/docs/get-details/accounts/#minimum-balance -@pytest.fixture() +@pytest.fixture def to_account(kmd_client: "KMDClient") -> Account: return create_kmd_wallet_account(kmd_client, get_unique_name()) -@pytest.fixture() +@pytest.fixture def rekeyed_from_account(algod_client: "AlgodClient", kmd_client: "KMDClient") -> Account: account = create_kmd_wallet_account(kmd_client, get_unique_name()) rekey_account = create_kmd_wallet_account(kmd_client, get_unique_name()) @@ -68,7 +68,7 @@ def rekeyed_from_account(algod_client: "AlgodClient", kmd_client: "KMDClient") - return Account(address=account.address, private_key=rekey_account.private_key) -@pytest.fixture() +@pytest.fixture def transaction_signer_from_account( kmd_client: "KMDClient", algod_client: "AlgodClient", @@ -87,7 +87,7 @@ def transaction_signer_from_account( return AccountTransactionSigner(private_key=account.private_key) -@pytest.fixture() +@pytest.fixture def clawback_account(kmd_client: "KMDClient") -> Account: return create_kmd_wallet_account(kmd_client, get_unique_name()) diff --git a/poetry.lock b/poetry.lock index 3544afa0..e173428a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -13,35 +13,35 @@ files = [ [[package]] name = "anyio" -version = "4.6.2.post1" +version = "4.7.0" description = "High level compatibility layer for multiple asynchronous event loop implementations" optional = false python-versions = ">=3.9" files = [ - {file = "anyio-4.6.2.post1-py3-none-any.whl", hash = "sha256:6d170c36fba3bdd840c73d3868c1e777e33676a69c3a72cf0a0d5d6d8009b61d"}, - {file = "anyio-4.6.2.post1.tar.gz", hash = "sha256:4c8bc31ccdb51c7f7bd251f51c609e038d63e34219b44aa86e47576389880b4c"}, + {file = "anyio-4.7.0-py3-none-any.whl", hash = "sha256:ea60c3723ab42ba6fff7e8ccb0488c898ec538ff4df1f1d5e642c3601d07e352"}, + {file = "anyio-4.7.0.tar.gz", hash = "sha256:2f834749c602966b7d456a7567cafcb309f96482b5081d14ac93ccd457f9dd48"}, ] [package.dependencies] exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} idna = ">=2.8" sniffio = ">=1.1" -typing-extensions = {version = ">=4.1", markers = "python_version < \"3.11\""} +typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} [package.extras] -doc = ["Sphinx (>=7.4,<8.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] -test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "truststore (>=0.9.1)", "uvloop (>=0.21.0b1)"] +doc = ["Sphinx (>=7.4,<8.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx_rtd_theme"] +test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "truststore (>=0.9.1)", "uvloop (>=0.21)"] trio = ["trio (>=0.26.1)"] [[package]] name = "astroid" -version = "3.3.5" +version = "3.3.6" description = "An abstract syntax tree for Python with inference support." optional = false python-versions = ">=3.9.0" files = [ - {file = "astroid-3.3.5-py3-none-any.whl", hash = "sha256:a9d1c946ada25098d790e079ba2a1b112157278f3fb7e718ae6a9252f5835dc8"}, - {file = "astroid-3.3.5.tar.gz", hash = "sha256:5cfc40ae9f68311075d27ef68a4841bdc5cc7f6cf86671b49f00607d30188e2d"}, + {file = "astroid-3.3.6-py3-none-any.whl", hash = "sha256:db676dc4f3ae6bfe31cda227dc60e03438378d7a896aec57422c95634e8d722f"}, + {file = "astroid-3.3.6.tar.gz", hash = "sha256:6aaea045f938c735ead292204afdb977a36e989522b7833ef6fea94de743f442"}, ] [package.dependencies] @@ -104,13 +104,13 @@ files = [ [[package]] name = "cachecontrol" -version = "0.14.0" +version = "0.14.1" description = "httplib2 caching for requests" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "cachecontrol-0.14.0-py3-none-any.whl", hash = "sha256:f5bf3f0620c38db2e5122c0726bdebb0d16869de966ea6a2befe92470b740ea0"}, - {file = "cachecontrol-0.14.0.tar.gz", hash = "sha256:7db1195b41c81f8274a7bbd97c956f44e8348265a1bc7641c37dfebc39f0c938"}, + {file = "cachecontrol-0.14.1-py3-none-any.whl", hash = "sha256:65e3abd62b06382ce3894df60dde9e0deb92aeb734724f68fa4f3b91e97206b9"}, + {file = "cachecontrol-0.14.1.tar.gz", hash = "sha256:06ef916a1e4eb7dba9948cdfc9c76e749db2e02104a9a1277e8b642591a0f717"}, ] [package.dependencies] @@ -119,7 +119,7 @@ msgpack = ">=0.5.2,<2.0.0" requests = ">=2.16.0" [package.extras] -dev = ["CacheControl[filecache,redis]", "black", "build", "cherrypy", "furo", "mypy", "pytest", "pytest-cov", "sphinx", "sphinx-copybutton", "tox", "types-redis", "types-requests"] +dev = ["CacheControl[filecache,redis]", "build", "cherrypy", "codespell[tomli]", "furo", "mypy", "pytest", "pytest-cov", "ruff", "sphinx", "sphinx-copybutton", "tox", "types-redis", "types-requests"] filecache = ["filelock (>=3.8.0)"] redis = ["redis (>=2.10.5)"] @@ -379,73 +379,73 @@ files = [ [[package]] name = "coverage" -version = "7.6.3" +version = "7.6.9" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.9" files = [ - {file = "coverage-7.6.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6da42bbcec130b188169107ecb6ee7bd7b4c849d24c9370a0c884cf728d8e976"}, - {file = "coverage-7.6.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c222958f59b0ae091f4535851cbb24eb57fc0baea07ba675af718fb5302dddb2"}, - {file = "coverage-7.6.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab84a8b698ad5a6c365b08061920138e7a7dd9a04b6feb09ba1bfae68346ce6d"}, - {file = "coverage-7.6.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70a6756ce66cd6fe8486c775b30889f0dc4cb20c157aa8c35b45fd7868255c5c"}, - {file = "coverage-7.6.3-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c2e6fa98032fec8282f6b27e3f3986c6e05702828380618776ad794e938f53a"}, - {file = "coverage-7.6.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:921fbe13492caf6a69528f09d5d7c7d518c8d0e7b9f6701b7719715f29a71e6e"}, - {file = "coverage-7.6.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6d99198203f0b9cb0b5d1c0393859555bc26b548223a769baf7e321a627ed4fc"}, - {file = "coverage-7.6.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:87cd2e29067ea397a47e352efb13f976eb1b03e18c999270bb50589323294c6e"}, - {file = "coverage-7.6.3-cp310-cp310-win32.whl", hash = "sha256:a3328c3e64ea4ab12b85999eb0779e6139295bbf5485f69d42cf794309e3d007"}, - {file = "coverage-7.6.3-cp310-cp310-win_amd64.whl", hash = "sha256:bca4c8abc50d38f9773c1ec80d43f3768df2e8576807d1656016b9d3eeaa96fd"}, - {file = "coverage-7.6.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c51ef82302386d686feea1c44dbeef744585da16fcf97deea2a8d6c1556f519b"}, - {file = "coverage-7.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0ca37993206402c6c35dc717f90d4c8f53568a8b80f0bf1a1b2b334f4d488fba"}, - {file = "coverage-7.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c77326300b839c44c3e5a8fe26c15b7e87b2f32dfd2fc9fee1d13604347c9b38"}, - {file = "coverage-7.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e484e479860e00da1f005cd19d1c5d4a813324e5951319ac3f3eefb497cc549"}, - {file = "coverage-7.6.3-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c6c0f4d53ef603397fc894a895b960ecd7d44c727df42a8d500031716d4e8d2"}, - {file = "coverage-7.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:37be7b5ea3ff5b7c4a9db16074dc94523b5f10dd1f3b362a827af66a55198175"}, - {file = "coverage-7.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:43b32a06c47539fe275106b376658638b418c7cfdfff0e0259fbf877e845f14b"}, - {file = "coverage-7.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ee77c7bef0724165e795b6b7bf9c4c22a9b8468a6bdb9c6b4281293c6b22a90f"}, - {file = "coverage-7.6.3-cp311-cp311-win32.whl", hash = "sha256:43517e1f6b19f610a93d8227e47790722c8bf7422e46b365e0469fc3d3563d97"}, - {file = "coverage-7.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:04f2189716e85ec9192df307f7c255f90e78b6e9863a03223c3b998d24a3c6c6"}, - {file = "coverage-7.6.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:27bd5f18d8f2879e45724b0ce74f61811639a846ff0e5c0395b7818fae87aec6"}, - {file = "coverage-7.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d546cfa78844b8b9c1c0533de1851569a13f87449897bbc95d698d1d3cb2a30f"}, - {file = "coverage-7.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9975442f2e7a5cfcf87299c26b5a45266ab0696348420049b9b94b2ad3d40234"}, - {file = "coverage-7.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:583049c63106c0555e3ae3931edab5669668bbef84c15861421b94e121878d3f"}, - {file = "coverage-7.6.3-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2341a78ae3a5ed454d524206a3fcb3cec408c2a0c7c2752cd78b606a2ff15af4"}, - {file = "coverage-7.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a4fb91d5f72b7e06a14ff4ae5be625a81cd7e5f869d7a54578fc271d08d58ae3"}, - {file = "coverage-7.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e279f3db904e3b55f520f11f983cc8dc8a4ce9b65f11692d4718ed021ec58b83"}, - {file = "coverage-7.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aa23ce39661a3e90eea5f99ec59b763b7d655c2cada10729ed920a38bfc2b167"}, - {file = "coverage-7.6.3-cp312-cp312-win32.whl", hash = "sha256:52ac29cc72ee7e25ace7807249638f94c9b6a862c56b1df015d2b2e388e51dbd"}, - {file = "coverage-7.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:40e8b1983080439d4802d80b951f4a93d991ef3261f69e81095a66f86cf3c3c6"}, - {file = "coverage-7.6.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9134032f5aa445ae591c2ba6991d10136a1f533b1d2fa8f8c21126468c5025c6"}, - {file = "coverage-7.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:99670790f21a96665a35849990b1df447993880bb6463a0a1d757897f30da929"}, - {file = "coverage-7.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2dc7d6b380ca76f5e817ac9eef0c3686e7834c8346bef30b041a4ad286449990"}, - {file = "coverage-7.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f7b26757b22faf88fcf232f5f0e62f6e0fd9e22a8a5d0d5016888cdfe1f6c1c4"}, - {file = "coverage-7.6.3-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c59d6a4a4633fad297f943c03d0d2569867bd5372eb5684befdff8df8522e39"}, - {file = "coverage-7.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f263b18692f8ed52c8de7f40a0751e79015983dbd77b16906e5b310a39d3ca21"}, - {file = "coverage-7.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:79644f68a6ff23b251cae1c82b01a0b51bc40c8468ca9585c6c4b1aeee570e0b"}, - {file = "coverage-7.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:71967c35828c9ff94e8c7d405469a1fb68257f686bca7c1ed85ed34e7c2529c4"}, - {file = "coverage-7.6.3-cp313-cp313-win32.whl", hash = "sha256:e266af4da2c1a4cbc6135a570c64577fd3e6eb204607eaff99d8e9b710003c6f"}, - {file = "coverage-7.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:ea52bd218d4ba260399a8ae4bb6b577d82adfc4518b93566ce1fddd4a49d1dce"}, - {file = "coverage-7.6.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8d4c6ea0f498c7c79111033a290d060c517853a7bcb2f46516f591dab628ddd3"}, - {file = "coverage-7.6.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:331b200ad03dbaa44151d74daeb7da2cf382db424ab923574f6ecca7d3b30de3"}, - {file = "coverage-7.6.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54356a76b67cf8a3085818026bb556545ebb8353951923b88292556dfa9f812d"}, - {file = "coverage-7.6.3-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ebec65f5068e7df2d49466aab9128510c4867e532e07cb6960075b27658dca38"}, - {file = "coverage-7.6.3-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d33a785ea8354c480515e781554d3be582a86297e41ccbea627a5c632647f2cd"}, - {file = "coverage-7.6.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f7ddb920106bbbbcaf2a274d56f46956bf56ecbde210d88061824a95bdd94e92"}, - {file = "coverage-7.6.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:70d24936ca6c15a3bbc91ee9c7fc661132c6f4c9d42a23b31b6686c05073bde5"}, - {file = "coverage-7.6.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c30e42ea11badb147f0d2e387115b15e2bd8205a5ad70d6ad79cf37f6ac08c91"}, - {file = "coverage-7.6.3-cp313-cp313t-win32.whl", hash = "sha256:365defc257c687ce3e7d275f39738dcd230777424117a6c76043459db131dd43"}, - {file = "coverage-7.6.3-cp313-cp313t-win_amd64.whl", hash = "sha256:23bb63ae3f4c645d2d82fa22697364b0046fbafb6261b258a58587441c5f7bd0"}, - {file = "coverage-7.6.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:da29ceabe3025a1e5a5aeeb331c5b1af686daab4ff0fb4f83df18b1180ea83e2"}, - {file = "coverage-7.6.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:df8c05a0f574d480947cba11b947dc41b1265d721c3777881da2fb8d3a1ddfba"}, - {file = "coverage-7.6.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec1e3b40b82236d100d259854840555469fad4db64f669ab817279eb95cd535c"}, - {file = "coverage-7.6.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b4adeb878a374126f1e5cf03b87f66279f479e01af0e9a654cf6d1509af46c40"}, - {file = "coverage-7.6.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:43d6a66e33b1455b98fc7312b124296dad97a2e191c80320587234a77b1b736e"}, - {file = "coverage-7.6.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1990b1f4e2c402beb317840030bb9f1b6a363f86e14e21b4212e618acdfce7f6"}, - {file = "coverage-7.6.3-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:12f9515d875859faedb4144fd38694a761cd2a61ef9603bf887b13956d0bbfbb"}, - {file = "coverage-7.6.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:99ded130555c021d99729fabd4ddb91a6f4cc0707df4b1daf912c7850c373b13"}, - {file = "coverage-7.6.3-cp39-cp39-win32.whl", hash = "sha256:c3a79f56dee9136084cf84a6c7c4341427ef36e05ae6415bf7d787c96ff5eaa3"}, - {file = "coverage-7.6.3-cp39-cp39-win_amd64.whl", hash = "sha256:aac7501ae73d4a02f4b7ac8fcb9dc55342ca98ffb9ed9f2dfb8a25d53eda0e4d"}, - {file = "coverage-7.6.3-pp39.pp310-none-any.whl", hash = "sha256:b9853509b4bf57ba7b1f99b9d866c422c9c5248799ab20e652bbb8a184a38181"}, - {file = "coverage-7.6.3.tar.gz", hash = "sha256:bb7d5fe92bd0dc235f63ebe9f8c6e0884f7360f88f3411bfed1350c872ef2054"}, + {file = "coverage-7.6.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:85d9636f72e8991a1706b2b55b06c27545448baf9f6dbf51c4004609aacd7dcb"}, + {file = "coverage-7.6.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:608a7fd78c67bee8936378299a6cb9f5149bb80238c7a566fc3e6717a4e68710"}, + {file = "coverage-7.6.9-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:96d636c77af18b5cb664ddf12dab9b15a0cfe9c0bde715da38698c8cea748bfa"}, + {file = "coverage-7.6.9-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d75cded8a3cff93da9edc31446872d2997e327921d8eed86641efafd350e1df1"}, + {file = "coverage-7.6.9-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7b15f589593110ae767ce997775d645b47e5cbbf54fd322f8ebea6277466cec"}, + {file = "coverage-7.6.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:44349150f6811b44b25574839b39ae35291f6496eb795b7366fef3bd3cf112d3"}, + {file = "coverage-7.6.9-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:d891c136b5b310d0e702e186d70cd16d1119ea8927347045124cb286b29297e5"}, + {file = "coverage-7.6.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:db1dab894cc139f67822a92910466531de5ea6034ddfd2b11c0d4c6257168073"}, + {file = "coverage-7.6.9-cp310-cp310-win32.whl", hash = "sha256:41ff7b0da5af71a51b53f501a3bac65fb0ec311ebed1632e58fc6107f03b9198"}, + {file = "coverage-7.6.9-cp310-cp310-win_amd64.whl", hash = "sha256:35371f8438028fdccfaf3570b31d98e8d9eda8bb1d6ab9473f5a390969e98717"}, + {file = "coverage-7.6.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:932fc826442132dde42ee52cf66d941f581c685a6313feebed358411238f60f9"}, + {file = "coverage-7.6.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:085161be5f3b30fd9b3e7b9a8c301f935c8313dcf928a07b116324abea2c1c2c"}, + {file = "coverage-7.6.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ccc660a77e1c2bf24ddbce969af9447a9474790160cfb23de6be4fa88e3951c7"}, + {file = "coverage-7.6.9-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c69e42c892c018cd3c8d90da61d845f50a8243062b19d228189b0224150018a9"}, + {file = "coverage-7.6.9-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0824a28ec542a0be22f60c6ac36d679e0e262e5353203bea81d44ee81fe9c6d4"}, + {file = "coverage-7.6.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4401ae5fc52ad8d26d2a5d8a7428b0f0c72431683f8e63e42e70606374c311a1"}, + {file = "coverage-7.6.9-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:98caba4476a6c8d59ec1eb00c7dd862ba9beca34085642d46ed503cc2d440d4b"}, + {file = "coverage-7.6.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ee5defd1733fd6ec08b168bd4f5387d5b322f45ca9e0e6c817ea6c4cd36313e3"}, + {file = "coverage-7.6.9-cp311-cp311-win32.whl", hash = "sha256:f2d1ec60d6d256bdf298cb86b78dd715980828f50c46701abc3b0a2b3f8a0dc0"}, + {file = "coverage-7.6.9-cp311-cp311-win_amd64.whl", hash = "sha256:0d59fd927b1f04de57a2ba0137166d31c1a6dd9e764ad4af552912d70428c92b"}, + {file = "coverage-7.6.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:99e266ae0b5d15f1ca8d278a668df6f51cc4b854513daab5cae695ed7b721cf8"}, + {file = "coverage-7.6.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9901d36492009a0a9b94b20e52ebfc8453bf49bb2b27bca2c9706f8b4f5a554a"}, + {file = "coverage-7.6.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:abd3e72dd5b97e3af4246cdada7738ef0e608168de952b837b8dd7e90341f015"}, + {file = "coverage-7.6.9-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ff74026a461eb0660366fb01c650c1d00f833a086b336bdad7ab00cc952072b3"}, + {file = "coverage-7.6.9-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65dad5a248823a4996724a88eb51d4b31587aa7aa428562dbe459c684e5787ae"}, + {file = "coverage-7.6.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:22be16571504c9ccea919fcedb459d5ab20d41172056206eb2994e2ff06118a4"}, + {file = "coverage-7.6.9-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f957943bc718b87144ecaee70762bc2bc3f1a7a53c7b861103546d3a403f0a6"}, + {file = "coverage-7.6.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ae1387db4aecb1f485fb70a6c0148c6cdaebb6038f1d40089b1fc84a5db556f"}, + {file = "coverage-7.6.9-cp312-cp312-win32.whl", hash = "sha256:1a330812d9cc7ac2182586f6d41b4d0fadf9be9049f350e0efb275c8ee8eb692"}, + {file = "coverage-7.6.9-cp312-cp312-win_amd64.whl", hash = "sha256:b12c6b18269ca471eedd41c1b6a1065b2f7827508edb9a7ed5555e9a56dcfc97"}, + {file = "coverage-7.6.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:899b8cd4781c400454f2f64f7776a5d87bbd7b3e7f7bda0cb18f857bb1334664"}, + {file = "coverage-7.6.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:61f70dc68bd36810972e55bbbe83674ea073dd1dcc121040a08cdf3416c5349c"}, + {file = "coverage-7.6.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8a289d23d4c46f1a82d5db4abeb40b9b5be91731ee19a379d15790e53031c014"}, + {file = "coverage-7.6.9-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7e216d8044a356fc0337c7a2a0536d6de07888d7bcda76febcb8adc50bdbbd00"}, + {file = "coverage-7.6.9-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c026eb44f744acaa2bda7493dad903aa5bf5fc4f2554293a798d5606710055d"}, + {file = "coverage-7.6.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e77363e8425325384f9d49272c54045bbed2f478e9dd698dbc65dbc37860eb0a"}, + {file = "coverage-7.6.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:777abfab476cf83b5177b84d7486497e034eb9eaea0d746ce0c1268c71652077"}, + {file = "coverage-7.6.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:447af20e25fdbe16f26e84eb714ba21d98868705cb138252d28bc400381f6ffb"}, + {file = "coverage-7.6.9-cp313-cp313-win32.whl", hash = "sha256:d872ec5aeb086cbea771c573600d47944eea2dcba8be5f3ee649bfe3cb8dc9ba"}, + {file = "coverage-7.6.9-cp313-cp313-win_amd64.whl", hash = "sha256:fd1213c86e48dfdc5a0cc676551db467495a95a662d2396ecd58e719191446e1"}, + {file = "coverage-7.6.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ba9e7484d286cd5a43744e5f47b0b3fb457865baf07bafc6bee91896364e1419"}, + {file = "coverage-7.6.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e5ea1cf0872ee455c03e5674b5bca5e3e68e159379c1af0903e89f5eba9ccc3a"}, + {file = "coverage-7.6.9-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d10e07aa2b91835d6abec555ec8b2733347956991901eea6ffac295f83a30e4"}, + {file = "coverage-7.6.9-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:13a9e2d3ee855db3dd6ea1ba5203316a1b1fd8eaeffc37c5b54987e61e4194ae"}, + {file = "coverage-7.6.9-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c38bf15a40ccf5619fa2fe8f26106c7e8e080d7760aeccb3722664c8656b030"}, + {file = "coverage-7.6.9-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d5275455b3e4627c8e7154feaf7ee0743c2e7af82f6e3b561967b1cca755a0be"}, + {file = "coverage-7.6.9-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8f8770dfc6e2c6a2d4569f411015c8d751c980d17a14b0530da2d7f27ffdd88e"}, + {file = "coverage-7.6.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8d2dfa71665a29b153a9681edb1c8d9c1ea50dfc2375fb4dac99ea7e21a0bcd9"}, + {file = "coverage-7.6.9-cp313-cp313t-win32.whl", hash = "sha256:5e6b86b5847a016d0fbd31ffe1001b63355ed309651851295315031ea7eb5a9b"}, + {file = "coverage-7.6.9-cp313-cp313t-win_amd64.whl", hash = "sha256:97ddc94d46088304772d21b060041c97fc16bdda13c6c7f9d8fcd8d5ae0d8611"}, + {file = "coverage-7.6.9-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:adb697c0bd35100dc690de83154627fbab1f4f3c0386df266dded865fc50a902"}, + {file = "coverage-7.6.9-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:be57b6d56e49c2739cdf776839a92330e933dd5e5d929966fbbd380c77f060be"}, + {file = "coverage-7.6.9-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1592791f8204ae9166de22ba7e6705fa4ebd02936c09436a1bb85aabca3e599"}, + {file = "coverage-7.6.9-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e12ae8cc979cf83d258acb5e1f1cf2f3f83524d1564a49d20b8bec14b637f08"}, + {file = "coverage-7.6.9-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb5555cff66c4d3d6213a296b360f9e1a8e323e74e0426b6c10ed7f4d021e464"}, + {file = "coverage-7.6.9-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:b9389a429e0e5142e69d5bf4a435dd688c14478a19bb901735cdf75e57b13845"}, + {file = "coverage-7.6.9-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:592ac539812e9b46046620341498caf09ca21023c41c893e1eb9dbda00a70cbf"}, + {file = "coverage-7.6.9-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a27801adef24cc30871da98a105f77995e13a25a505a0161911f6aafbd66e678"}, + {file = "coverage-7.6.9-cp39-cp39-win32.whl", hash = "sha256:8e3c3e38930cfb729cb8137d7f055e5a473ddaf1217966aa6238c88bd9fd50e6"}, + {file = "coverage-7.6.9-cp39-cp39-win_amd64.whl", hash = "sha256:e28bf44afa2b187cc9f41749138a64435bf340adfcacb5b2290c070ce99839d4"}, + {file = "coverage-7.6.9-pp39.pp310-none-any.whl", hash = "sha256:f3ca78518bc6bc92828cd11867b121891d75cae4ea9e908d72030609b996db1b"}, + {file = "coverage-7.6.9.tar.gz", hash = "sha256:4a8d8977b0c6ef5aeadcb644da9e69ae0dcfe66ec7f368c89c72e058bd71164d"}, ] [package.dependencies] @@ -456,51 +456,53 @@ toml = ["tomli"] [[package]] name = "cryptography" -version = "43.0.1" +version = "44.0.0" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false -python-versions = ">=3.7" -files = [ - {file = "cryptography-43.0.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:8385d98f6a3bf8bb2d65a73e17ed87a3ba84f6991c155691c51112075f9ffc5d"}, - {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27e613d7077ac613e399270253259d9d53872aaf657471473ebfc9a52935c062"}, - {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68aaecc4178e90719e95298515979814bda0cbada1256a4485414860bd7ab962"}, - {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:de41fd81a41e53267cb020bb3a7212861da53a7d39f863585d13ea11049cf277"}, - {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f98bf604c82c416bc829e490c700ca1553eafdf2912a91e23a79d97d9801372a"}, - {file = "cryptography-43.0.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:61ec41068b7b74268fa86e3e9e12b9f0c21fcf65434571dbb13d954bceb08042"}, - {file = "cryptography-43.0.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:014f58110f53237ace6a408b5beb6c427b64e084eb451ef25a28308270086494"}, - {file = "cryptography-43.0.1-cp37-abi3-win32.whl", hash = "sha256:2bd51274dcd59f09dd952afb696bf9c61a7a49dfc764c04dd33ef7a6b502a1e2"}, - {file = "cryptography-43.0.1-cp37-abi3-win_amd64.whl", hash = "sha256:666ae11966643886c2987b3b721899d250855718d6d9ce41b521252a17985f4d"}, - {file = "cryptography-43.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:ac119bb76b9faa00f48128b7f5679e1d8d437365c5d26f1c2c3f0da4ce1b553d"}, - {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bbcce1a551e262dfbafb6e6252f1ae36a248e615ca44ba302df077a846a8806"}, - {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58d4e9129985185a06d849aa6df265bdd5a74ca6e1b736a77959b498e0505b85"}, - {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d03a475165f3134f773d1388aeb19c2d25ba88b6a9733c5c590b9ff7bbfa2e0c"}, - {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:511f4273808ab590912a93ddb4e3914dfd8a388fed883361b02dea3791f292e1"}, - {file = "cryptography-43.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:80eda8b3e173f0f247f711eef62be51b599b5d425c429b5d4ca6a05e9e856baa"}, - {file = "cryptography-43.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38926c50cff6f533f8a2dae3d7f19541432610d114a70808f0926d5aaa7121e4"}, - {file = "cryptography-43.0.1-cp39-abi3-win32.whl", hash = "sha256:a575913fb06e05e6b4b814d7f7468c2c660e8bb16d8d5a1faf9b33ccc569dd47"}, - {file = "cryptography-43.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:d75601ad10b059ec832e78823b348bfa1a59f6b8d545db3a24fd44362a1564cb"}, - {file = "cryptography-43.0.1-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ea25acb556320250756e53f9e20a4177515f012c9eaea17eb7587a8c4d8ae034"}, - {file = "cryptography-43.0.1-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c1332724be35d23a854994ff0b66530119500b6053d0bd3363265f7e5e77288d"}, - {file = "cryptography-43.0.1-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fba1007b3ef89946dbbb515aeeb41e30203b004f0b4b00e5e16078b518563289"}, - {file = "cryptography-43.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:5b43d1ea6b378b54a1dc99dd8a2b5be47658fe9a7ce0a58ff0b55f4b43ef2b84"}, - {file = "cryptography-43.0.1-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:88cce104c36870d70c49c7c8fd22885875d950d9ee6ab54df2745f83ba0dc365"}, - {file = "cryptography-43.0.1-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:9d3cdb25fa98afdd3d0892d132b8d7139e2c087da1712041f6b762e4f807cc96"}, - {file = "cryptography-43.0.1-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e710bf40870f4db63c3d7d929aa9e09e4e7ee219e703f949ec4073b4294f6172"}, - {file = "cryptography-43.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7c05650fe8023c5ed0d46793d4b7d7e6cd9c04e68eabe5b0aeea836e37bdcec2"}, - {file = "cryptography-43.0.1.tar.gz", hash = "sha256:203e92a75716d8cfb491dc47c79e17d0d9207ccffcbcb35f598fbe463ae3444d"}, +python-versions = "!=3.9.0,!=3.9.1,>=3.7" +files = [ + {file = "cryptography-44.0.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:84111ad4ff3f6253820e6d3e58be2cc2a00adb29335d4cacb5ab4d4d34f2a123"}, + {file = "cryptography-44.0.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b15492a11f9e1b62ba9d73c210e2416724633167de94607ec6069ef724fad092"}, + {file = "cryptography-44.0.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:831c3c4d0774e488fdc83a1923b49b9957d33287de923d58ebd3cec47a0ae43f"}, + {file = "cryptography-44.0.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:761817a3377ef15ac23cd7834715081791d4ec77f9297ee694ca1ee9c2c7e5eb"}, + {file = "cryptography-44.0.0-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3c672a53c0fb4725a29c303be906d3c1fa99c32f58abe008a82705f9ee96f40b"}, + {file = "cryptography-44.0.0-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:4ac4c9f37eba52cb6fbeaf5b59c152ea976726b865bd4cf87883a7e7006cc543"}, + {file = "cryptography-44.0.0-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:60eb32934076fa07e4316b7b2742fa52cbb190b42c2df2863dbc4230a0a9b385"}, + {file = "cryptography-44.0.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ed3534eb1090483c96178fcb0f8893719d96d5274dfde98aa6add34614e97c8e"}, + {file = "cryptography-44.0.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f3f6fdfa89ee2d9d496e2c087cebef9d4fcbb0ad63c40e821b39f74bf48d9c5e"}, + {file = "cryptography-44.0.0-cp37-abi3-win32.whl", hash = "sha256:eb33480f1bad5b78233b0ad3e1b0be21e8ef1da745d8d2aecbb20671658b9053"}, + {file = "cryptography-44.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:abc998e0c0eee3c8a1904221d3f67dcfa76422b23620173e28c11d3e626c21bd"}, + {file = "cryptography-44.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:660cb7312a08bc38be15b696462fa7cc7cd85c3ed9c576e81f4dc4d8b2b31591"}, + {file = "cryptography-44.0.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1923cb251c04be85eec9fda837661c67c1049063305d6be5721643c22dd4e2b7"}, + {file = "cryptography-44.0.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:404fdc66ee5f83a1388be54300ae978b2efd538018de18556dde92575e05defc"}, + {file = "cryptography-44.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:c5eb858beed7835e5ad1faba59e865109f3e52b3783b9ac21e7e47dc5554e289"}, + {file = "cryptography-44.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f53c2c87e0fb4b0c00fa9571082a057e37690a8f12233306161c8f4b819960b7"}, + {file = "cryptography-44.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:9e6fc8a08e116fb7c7dd1f040074c9d7b51d74a8ea40d4df2fc7aa08b76b9e6c"}, + {file = "cryptography-44.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9abcc2e083cbe8dde89124a47e5e53ec38751f0d7dfd36801008f316a127d7ba"}, + {file = "cryptography-44.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d2436114e46b36d00f8b72ff57e598978b37399d2786fd39793c36c6d5cb1c64"}, + {file = "cryptography-44.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a01956ddfa0a6790d594f5b34fc1bfa6098aca434696a03cfdbe469b8ed79285"}, + {file = "cryptography-44.0.0-cp39-abi3-win32.whl", hash = "sha256:eca27345e1214d1b9f9490d200f9db5a874479be914199194e746c893788d417"}, + {file = "cryptography-44.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:708ee5f1bafe76d041b53a4f95eb28cdeb8d18da17e597d46d7833ee59b97ede"}, + {file = "cryptography-44.0.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:37d76e6863da3774cd9db5b409a9ecfd2c71c981c38788d3fcfaf177f447b731"}, + {file = "cryptography-44.0.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:f677e1268c4e23420c3acade68fac427fffcb8d19d7df95ed7ad17cdef8404f4"}, + {file = "cryptography-44.0.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:f5e7cb1e5e56ca0933b4873c0220a78b773b24d40d186b6738080b73d3d0a756"}, + {file = "cryptography-44.0.0-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:8b3e6eae66cf54701ee7d9c83c30ac0a1e3fa17be486033000f2a73a12ab507c"}, + {file = "cryptography-44.0.0-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:be4ce505894d15d5c5037167ffb7f0ae90b7be6f2a98f9a5c3442395501c32fa"}, + {file = "cryptography-44.0.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:62901fb618f74d7d81bf408c8719e9ec14d863086efe4185afd07c352aee1d2c"}, + {file = "cryptography-44.0.0.tar.gz", hash = "sha256:cd4e834f340b4293430701e772ec543b0fbe6c2dea510a5286fe0acabe153a02"}, ] [package.dependencies] cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""} [package.extras] -docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"] -docstest = ["pyenchant (>=1.6.11)", "readme-renderer", "sphinxcontrib-spelling (>=4.0.1)"] -nox = ["nox"] -pep8test = ["check-sdist", "click", "mypy", "ruff"] -sdist = ["build"] +docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=3.0.0)"] +docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"] +nox = ["nox (>=2024.4.15)", "nox[uv] (>=2024.3.2)"] +pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.4)", "ruff (>=0.3.6)"] +sdist = ["build (>=1.0.0)"] ssh = ["bcrypt (>=3.1.5)"] -test = ["certifi", "cryptography-vectors (==43.0.1)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] +test = ["certifi (>=2024)", "cryptography-vectors (==44.0.0)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] test-randomorder = ["pytest-randomly"] [[package]] @@ -538,20 +540,20 @@ files = [ [[package]] name = "deprecated" -version = "1.2.14" +version = "1.2.15" description = "Python @deprecated decorator to deprecate old python classes, functions or methods." optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" files = [ - {file = "Deprecated-1.2.14-py2.py3-none-any.whl", hash = "sha256:6fac8b097794a90302bdbb17b9b815e732d3c4720583ff1b198499d78470466c"}, - {file = "Deprecated-1.2.14.tar.gz", hash = "sha256:e5323eb936458dccc2582dc6f9c322c852a775a27065ff2b0c4970b9d53d01b3"}, + {file = "Deprecated-1.2.15-py2.py3-none-any.whl", hash = "sha256:353bc4a8ac4bfc96800ddab349d89c25dec1079f65fd53acdcc1e0b975b21320"}, + {file = "deprecated-1.2.15.tar.gz", hash = "sha256:683e561a90de76239796e6b6feac66b99030d2dd3fcf61ef996330f14bbb9b0d"}, ] [package.dependencies] wrapt = ">=1.10,<2" [package.extras] -dev = ["PyTest", "PyTest-Cov", "bump2version (<1)", "sphinx (<2)", "tox"] +dev = ["PyTest", "PyTest-Cov", "bump2version (<1)", "jinja2 (>=3.0.3,<3.1.0)", "setuptools", "sphinx (<2)", "tox"] [[package]] name = "distlib" @@ -765,13 +767,13 @@ socks = ["socksio (==1.*)"] [[package]] name = "identify" -version = "2.6.1" +version = "2.6.3" description = "File identification library for Python" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "identify-2.6.1-py2.py3-none-any.whl", hash = "sha256:53863bcac7caf8d2ed85bd20312ea5dcfc22226800f6d6881f232d861db5a8f0"}, - {file = "identify-2.6.1.tar.gz", hash = "sha256:91478c5fb7c3aac5ff7bf9b4344f803843dc586832d5f110d672b19aa1984c98"}, + {file = "identify-2.6.3-py2.py3-none-any.whl", hash = "sha256:9edba65473324c2ea9684b1f944fe3191db3345e50b6d04571d10ed164f8d7bd"}, + {file = "identify-2.6.3.tar.gz", hash = "sha256:62f5dae9b5fef52c84cc188514e9ea4f3f636b1d8799ab5ebc475471f9e47a02"}, ] [package.extras] @@ -939,13 +941,13 @@ i18n = ["Babel (>=2.7)"] [[package]] name = "keyring" -version = "25.4.1" +version = "25.5.0" description = "Store and access your passwords safely." optional = false python-versions = ">=3.8" files = [ - {file = "keyring-25.4.1-py3-none-any.whl", hash = "sha256:5426f817cf7f6f007ba5ec722b1bcad95a75b27d780343772ad76b17cb47b0bf"}, - {file = "keyring-25.4.1.tar.gz", hash = "sha256:b07ebc55f3e8ed86ac81dd31ef14e81ace9dd9c3d4b5d77a6e9a2016d0d71a1b"}, + {file = "keyring-25.5.0-py3-none-any.whl", hash = "sha256:e67f8ac32b04be4714b42fe84ce7dad9c40985b9ca827c592cc303e7c26d9741"}, + {file = "keyring-25.5.0.tar.gz", hash = "sha256:4c753b3ec91717fe713c4edd522d625889d8973a349b0e582622f49766de58e6"}, ] [package.dependencies] @@ -968,13 +970,13 @@ type = ["pygobject-stubs", "pytest-mypy", "shtab", "types-pywin32"] [[package]] name = "license-expression" -version = "30.3.1" +version = "30.4.0" description = "license-expression is a comprehensive utility library to parse, compare, simplify and normalize license expressions (such as SPDX license expressions) using boolean logic." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "license_expression-30.3.1-py3-none-any.whl", hash = "sha256:97904b9185c7bbb1e98799606fa7424191c375e70ba63a524b6f7100e42ddc46"}, - {file = "license_expression-30.3.1.tar.gz", hash = "sha256:60d5bec1f3364c256a92b9a08583d7ea933c7aa272c8d36d04144a89a3858c01"}, + {file = "license_expression-30.4.0-py3-none-any.whl", hash = "sha256:7c8f240c6e20d759cb8455e49cb44a923d9e25c436bf48d7e5b8eea660782c04"}, + {file = "license_expression-30.4.0.tar.gz", hash = "sha256:6464397f8ed4353cc778999caec43b099f8d8d5b335f282e26a9eb9435522f05"}, ] [package.dependencies] @@ -1030,72 +1032,72 @@ testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] [[package]] name = "markupsafe" -version = "3.0.1" +version = "3.0.2" description = "Safely add untrusted strings to HTML/XML markup." optional = false python-versions = ">=3.9" files = [ - {file = "MarkupSafe-3.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:db842712984e91707437461930e6011e60b39136c7331e971952bb30465bc1a1"}, - {file = "MarkupSafe-3.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3ffb4a8e7d46ed96ae48805746755fadd0909fea2306f93d5d8233ba23dda12a"}, - {file = "MarkupSafe-3.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67c519635a4f64e495c50e3107d9b4075aec33634272b5db1cde839e07367589"}, - {file = "MarkupSafe-3.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48488d999ed50ba8d38c581d67e496f955821dc183883550a6fbc7f1aefdc170"}, - {file = "MarkupSafe-3.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f31ae06f1328595d762c9a2bf29dafd8621c7d3adc130cbb46278079758779ca"}, - {file = "MarkupSafe-3.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:80fcbf3add8790caddfab6764bde258b5d09aefbe9169c183f88a7410f0f6dea"}, - {file = "MarkupSafe-3.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3341c043c37d78cc5ae6e3e305e988532b072329639007fd408a476642a89fd6"}, - {file = "MarkupSafe-3.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cb53e2a99df28eee3b5f4fea166020d3ef9116fdc5764bc5117486e6d1211b25"}, - {file = "MarkupSafe-3.0.1-cp310-cp310-win32.whl", hash = "sha256:db15ce28e1e127a0013dfb8ac243a8e392db8c61eae113337536edb28bdc1f97"}, - {file = "MarkupSafe-3.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:4ffaaac913c3f7345579db4f33b0020db693f302ca5137f106060316761beea9"}, - {file = "MarkupSafe-3.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:26627785a54a947f6d7336ce5963569b5d75614619e75193bdb4e06e21d447ad"}, - {file = "MarkupSafe-3.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b954093679d5750495725ea6f88409946d69cfb25ea7b4c846eef5044194f583"}, - {file = "MarkupSafe-3.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:973a371a55ce9ed333a3a0f8e0bcfae9e0d637711534bcb11e130af2ab9334e7"}, - {file = "MarkupSafe-3.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:244dbe463d5fb6d7ce161301a03a6fe744dac9072328ba9fc82289238582697b"}, - {file = "MarkupSafe-3.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d98e66a24497637dd31ccab090b34392dddb1f2f811c4b4cd80c230205c074a3"}, - {file = "MarkupSafe-3.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ad91738f14eb8da0ff82f2acd0098b6257621410dcbd4df20aaa5b4233d75a50"}, - {file = "MarkupSafe-3.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7044312a928a66a4c2a22644147bc61a199c1709712069a344a3fb5cfcf16915"}, - {file = "MarkupSafe-3.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a4792d3b3a6dfafefdf8e937f14906a51bd27025a36f4b188728a73382231d91"}, - {file = "MarkupSafe-3.0.1-cp311-cp311-win32.whl", hash = "sha256:fa7d686ed9883f3d664d39d5a8e74d3c5f63e603c2e3ff0abcba23eac6542635"}, - {file = "MarkupSafe-3.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:9ba25a71ebf05b9bb0e2ae99f8bc08a07ee8e98c612175087112656ca0f5c8bf"}, - {file = "MarkupSafe-3.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8ae369e84466aa70f3154ee23c1451fda10a8ee1b63923ce76667e3077f2b0c4"}, - {file = "MarkupSafe-3.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40f1e10d51c92859765522cbd79c5c8989f40f0419614bcdc5015e7b6bf97fc5"}, - {file = "MarkupSafe-3.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5a4cb365cb49b750bdb60b846b0c0bc49ed62e59a76635095a179d440540c346"}, - {file = "MarkupSafe-3.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee3941769bd2522fe39222206f6dd97ae83c442a94c90f2b7a25d847d40f4729"}, - {file = "MarkupSafe-3.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62fada2c942702ef8952754abfc1a9f7658a4d5460fabe95ac7ec2cbe0d02abc"}, - {file = "MarkupSafe-3.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c2d64fdba74ad16138300815cfdc6ab2f4647e23ced81f59e940d7d4a1469d9"}, - {file = "MarkupSafe-3.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:fb532dd9900381d2e8f48172ddc5a59db4c445a11b9fab40b3b786da40d3b56b"}, - {file = "MarkupSafe-3.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0f84af7e813784feb4d5e4ff7db633aba6c8ca64a833f61d8e4eade234ef0c38"}, - {file = "MarkupSafe-3.0.1-cp312-cp312-win32.whl", hash = "sha256:cbf445eb5628981a80f54087f9acdbf84f9b7d862756110d172993b9a5ae81aa"}, - {file = "MarkupSafe-3.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:a10860e00ded1dd0a65b83e717af28845bb7bd16d8ace40fe5531491de76b79f"}, - {file = "MarkupSafe-3.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e81c52638315ff4ac1b533d427f50bc0afc746deb949210bc85f05d4f15fd772"}, - {file = "MarkupSafe-3.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:312387403cd40699ab91d50735ea7a507b788091c416dd007eac54434aee51da"}, - {file = "MarkupSafe-3.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ae99f31f47d849758a687102afdd05bd3d3ff7dbab0a8f1587981b58a76152a"}, - {file = "MarkupSafe-3.0.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c97ff7fedf56d86bae92fa0a646ce1a0ec7509a7578e1ed238731ba13aabcd1c"}, - {file = "MarkupSafe-3.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7420ceda262dbb4b8d839a4ec63d61c261e4e77677ed7c66c99f4e7cb5030dd"}, - {file = "MarkupSafe-3.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:45d42d132cff577c92bfba536aefcfea7e26efb975bd455db4e6602f5c9f45e7"}, - {file = "MarkupSafe-3.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4c8817557d0de9349109acb38b9dd570b03cc5014e8aabf1cbddc6e81005becd"}, - {file = "MarkupSafe-3.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6a54c43d3ec4cf2a39f4387ad044221c66a376e58c0d0e971d47c475ba79c6b5"}, - {file = "MarkupSafe-3.0.1-cp313-cp313-win32.whl", hash = "sha256:c91b394f7601438ff79a4b93d16be92f216adb57d813a78be4446fe0f6bc2d8c"}, - {file = "MarkupSafe-3.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:fe32482b37b4b00c7a52a07211b479653b7fe4f22b2e481b9a9b099d8a430f2f"}, - {file = "MarkupSafe-3.0.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:17b2aea42a7280db02ac644db1d634ad47dcc96faf38ab304fe26ba2680d359a"}, - {file = "MarkupSafe-3.0.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:852dc840f6d7c985603e60b5deaae1d89c56cb038b577f6b5b8c808c97580f1d"}, - {file = "MarkupSafe-3.0.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0778de17cff1acaeccc3ff30cd99a3fd5c50fc58ad3d6c0e0c4c58092b859396"}, - {file = "MarkupSafe-3.0.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:800100d45176652ded796134277ecb13640c1a537cad3b8b53da45aa96330453"}, - {file = "MarkupSafe-3.0.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d06b24c686a34c86c8c1fba923181eae6b10565e4d80bdd7bc1c8e2f11247aa4"}, - {file = "MarkupSafe-3.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:33d1c36b90e570ba7785dacd1faaf091203d9942bc036118fab8110a401eb1a8"}, - {file = "MarkupSafe-3.0.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:beeebf760a9c1f4c07ef6a53465e8cfa776ea6a2021eda0d0417ec41043fe984"}, - {file = "MarkupSafe-3.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:bbde71a705f8e9e4c3e9e33db69341d040c827c7afa6789b14c6e16776074f5a"}, - {file = "MarkupSafe-3.0.1-cp313-cp313t-win32.whl", hash = "sha256:82b5dba6eb1bcc29cc305a18a3c5365d2af06ee71b123216416f7e20d2a84e5b"}, - {file = "MarkupSafe-3.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:730d86af59e0e43ce277bb83970530dd223bf7f2a838e086b50affa6ec5f9295"}, - {file = "MarkupSafe-3.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:4935dd7883f1d50e2ffecca0aa33dc1946a94c8f3fdafb8df5c330e48f71b132"}, - {file = "MarkupSafe-3.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e9393357f19954248b00bed7c56f29a25c930593a77630c719653d51e7669c2a"}, - {file = "MarkupSafe-3.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40621d60d0e58aa573b68ac5e2d6b20d44392878e0bfc159012a5787c4e35bc8"}, - {file = "MarkupSafe-3.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f94190df587738280d544971500b9cafc9b950d32efcb1fba9ac10d84e6aa4e6"}, - {file = "MarkupSafe-3.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b6a387d61fe41cdf7ea95b38e9af11cfb1a63499af2759444b99185c4ab33f5b"}, - {file = "MarkupSafe-3.0.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:8ad4ad1429cd4f315f32ef263c1342166695fad76c100c5d979c45d5570ed58b"}, - {file = "MarkupSafe-3.0.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:e24bfe89c6ac4c31792793ad9f861b8f6dc4546ac6dc8f1c9083c7c4f2b335cd"}, - {file = "MarkupSafe-3.0.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2a4b34a8d14649315c4bc26bbfa352663eb51d146e35eef231dd739d54a5430a"}, - {file = "MarkupSafe-3.0.1-cp39-cp39-win32.whl", hash = "sha256:242d6860f1fd9191aef5fae22b51c5c19767f93fb9ead4d21924e0bcb17619d8"}, - {file = "MarkupSafe-3.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:93e8248d650e7e9d49e8251f883eed60ecbc0e8ffd6349e18550925e31bd029b"}, - {file = "markupsafe-3.0.1.tar.gz", hash = "sha256:3e683ee4f5d0fa2dde4db77ed8dd8a876686e3fc417655c2ece9a90576905344"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a"}, + {file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"}, ] [[package]] @@ -1214,43 +1216,43 @@ files = [ [[package]] name = "mypy" -version = "1.12.0" +version = "1.13.0" description = "Optional static typing for Python" optional = false python-versions = ">=3.8" files = [ - {file = "mypy-1.12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4397081e620dc4dc18e2f124d5e1d2c288194c2c08df6bdb1db31c38cd1fe1ed"}, - {file = "mypy-1.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:684a9c508a283f324804fea3f0effeb7858eb03f85c4402a967d187f64562469"}, - {file = "mypy-1.12.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cabe4cda2fa5eca7ac94854c6c37039324baaa428ecbf4de4567279e9810f9e"}, - {file = "mypy-1.12.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:060a07b10e999ac9e7fa249ce2bdcfa9183ca2b70756f3bce9df7a92f78a3c0a"}, - {file = "mypy-1.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:0eff042d7257f39ba4ca06641d110ca7d2ad98c9c1fb52200fe6b1c865d360ff"}, - {file = "mypy-1.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4b86de37a0da945f6d48cf110d5206c5ed514b1ca2614d7ad652d4bf099c7de7"}, - {file = "mypy-1.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:20c7c5ce0c1be0b0aea628374e6cf68b420bcc772d85c3c974f675b88e3e6e57"}, - {file = "mypy-1.12.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a64ee25f05fc2d3d8474985c58042b6759100a475f8237da1f4faf7fcd7e6309"}, - {file = "mypy-1.12.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:faca7ab947c9f457a08dcb8d9a8664fd438080e002b0fa3e41b0535335edcf7f"}, - {file = "mypy-1.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:5bc81701d52cc8767005fdd2a08c19980de9ec61a25dbd2a937dfb1338a826f9"}, - {file = "mypy-1.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8462655b6694feb1c99e433ea905d46c478041a8b8f0c33f1dab00ae881b2164"}, - {file = "mypy-1.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:923ea66d282d8af9e0f9c21ffc6653643abb95b658c3a8a32dca1eff09c06475"}, - {file = "mypy-1.12.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ebf9e796521f99d61864ed89d1fb2926d9ab6a5fab421e457cd9c7e4dd65aa9"}, - {file = "mypy-1.12.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e478601cc3e3fa9d6734d255a59c7a2e5c2934da4378f3dd1e3411ea8a248642"}, - {file = "mypy-1.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:c72861b7139a4f738344faa0e150834467521a3fba42dc98264e5aa9507dd601"}, - {file = "mypy-1.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52b9e1492e47e1790360a43755fa04101a7ac72287b1a53ce817f35899ba0521"}, - {file = "mypy-1.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:48d3e37dd7d9403e38fa86c46191de72705166d40b8c9f91a3de77350daa0893"}, - {file = "mypy-1.12.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f106db5ccb60681b622ac768455743ee0e6a857724d648c9629a9bd2ac3f721"}, - {file = "mypy-1.12.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:233e11b3f73ee1f10efada2e6da0f555b2f3a5316e9d8a4a1224acc10e7181d3"}, - {file = "mypy-1.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:4ae8959c21abcf9d73aa6c74a313c45c0b5a188752bf37dace564e29f06e9c1b"}, - {file = "mypy-1.12.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:eafc1b7319b40ddabdc3db8d7d48e76cfc65bbeeafaa525a4e0fa6b76175467f"}, - {file = "mypy-1.12.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9b9ce1ad8daeb049c0b55fdb753d7414260bad8952645367e70ac91aec90e07e"}, - {file = "mypy-1.12.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfe012b50e1491d439172c43ccb50db66d23fab714d500b57ed52526a1020bb7"}, - {file = "mypy-1.12.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2c40658d4fa1ab27cb53d9e2f1066345596af2f8fe4827defc398a09c7c9519b"}, - {file = "mypy-1.12.0-cp38-cp38-win_amd64.whl", hash = "sha256:dee78a8b9746c30c1e617ccb1307b351ded57f0de0d287ca6276378d770006c0"}, - {file = "mypy-1.12.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6b5df6c8a8224f6b86746bda716bbe4dbe0ce89fd67b1fa4661e11bfe38e8ec8"}, - {file = "mypy-1.12.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5feee5c74eb9749e91b77f60b30771563327329e29218d95bedbe1257e2fe4b0"}, - {file = "mypy-1.12.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:77278e8c6ffe2abfba6db4125de55f1024de9a323be13d20e4f73b8ed3402bd1"}, - {file = "mypy-1.12.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:dcfb754dea911039ac12434d1950d69a2f05acd4d56f7935ed402be09fad145e"}, - {file = "mypy-1.12.0-cp39-cp39-win_amd64.whl", hash = "sha256:06de0498798527451ffb60f68db0d368bd2bae2bbfb5237eae616d4330cc87aa"}, - {file = "mypy-1.12.0-py3-none-any.whl", hash = "sha256:fd313226af375d52e1e36c383f39bf3836e1f192801116b31b090dfcd3ec5266"}, - {file = "mypy-1.12.0.tar.gz", hash = "sha256:65a22d87e757ccd95cbbf6f7e181e6caa87128255eb2b6be901bb71b26d8a99d"}, + {file = "mypy-1.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6607e0f1dd1fb7f0aca14d936d13fd19eba5e17e1cd2a14f808fa5f8f6d8f60a"}, + {file = "mypy-1.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8a21be69bd26fa81b1f80a61ee7ab05b076c674d9b18fb56239d72e21d9f4c80"}, + {file = "mypy-1.13.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b2353a44d2179846a096e25691d54d59904559f4232519d420d64da6828a3a7"}, + {file = "mypy-1.13.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0730d1c6a2739d4511dc4253f8274cdd140c55c32dfb0a4cf8b7a43f40abfa6f"}, + {file = "mypy-1.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:c5fc54dbb712ff5e5a0fca797e6e0aa25726c7e72c6a5850cfd2adbc1eb0a372"}, + {file = "mypy-1.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:581665e6f3a8a9078f28d5502f4c334c0c8d802ef55ea0e7276a6e409bc0d82d"}, + {file = "mypy-1.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3ddb5b9bf82e05cc9a627e84707b528e5c7caaa1c55c69e175abb15a761cec2d"}, + {file = "mypy-1.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20c7ee0bc0d5a9595c46f38beb04201f2620065a93755704e141fcac9f59db2b"}, + {file = "mypy-1.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3790ded76f0b34bc9c8ba4def8f919dd6a46db0f5a6610fb994fe8efdd447f73"}, + {file = "mypy-1.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51f869f4b6b538229c1d1bcc1dd7d119817206e2bc54e8e374b3dfa202defcca"}, + {file = "mypy-1.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5c7051a3461ae84dfb5dd15eff5094640c61c5f22257c8b766794e6dd85e72d5"}, + {file = "mypy-1.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:39bb21c69a5d6342f4ce526e4584bc5c197fd20a60d14a8624d8743fffb9472e"}, + {file = "mypy-1.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:164f28cb9d6367439031f4c81e84d3ccaa1e19232d9d05d37cb0bd880d3f93c2"}, + {file = "mypy-1.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a4c1bfcdbce96ff5d96fc9b08e3831acb30dc44ab02671eca5953eadad07d6d0"}, + {file = "mypy-1.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0affb3a79a256b4183ba09811e3577c5163ed06685e4d4b46429a271ba174d2"}, + {file = "mypy-1.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a7b44178c9760ce1a43f544e595d35ed61ac2c3de306599fa59b38a6048e1aa7"}, + {file = "mypy-1.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5d5092efb8516d08440e36626f0153b5006d4088c1d663d88bf79625af3d1d62"}, + {file = "mypy-1.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2904956dac40ced10931ac967ae63c5089bd498542194b436eb097a9f77bc8"}, + {file = "mypy-1.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:7bfd8836970d33c2105562650656b6846149374dc8ed77d98424b40b09340ba7"}, + {file = "mypy-1.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:9f73dba9ec77acb86457a8fc04b5239822df0c14a082564737833d2963677dbc"}, + {file = "mypy-1.13.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:100fac22ce82925f676a734af0db922ecfea991e1d7ec0ceb1e115ebe501301a"}, + {file = "mypy-1.13.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7bcb0bb7f42a978bb323a7c88f1081d1b5dee77ca86f4100735a6f541299d8fb"}, + {file = "mypy-1.13.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bde31fc887c213e223bbfc34328070996061b0833b0a4cfec53745ed61f3519b"}, + {file = "mypy-1.13.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:07de989f89786f62b937851295ed62e51774722e5444a27cecca993fc3f9cd74"}, + {file = "mypy-1.13.0-cp38-cp38-win_amd64.whl", hash = "sha256:4bde84334fbe19bad704b3f5b78c4abd35ff1026f8ba72b29de70dda0916beb6"}, + {file = "mypy-1.13.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0246bcb1b5de7f08f2826451abd947bf656945209b140d16ed317f65a17dc7dc"}, + {file = "mypy-1.13.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7f5b7deae912cf8b77e990b9280f170381fdfbddf61b4ef80927edd813163732"}, + {file = "mypy-1.13.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7029881ec6ffb8bc233a4fa364736789582c738217b133f1b55967115288a2bc"}, + {file = "mypy-1.13.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:3e38b980e5681f28f033f3be86b099a247b13c491f14bb8b1e1e134d23bb599d"}, + {file = "mypy-1.13.0-cp39-cp39-win_amd64.whl", hash = "sha256:a6789be98a2017c912ae6ccb77ea553bbaf13d27605d2ca20a76dfbced631b24"}, + {file = "mypy-1.13.0-py3-none-any.whl", hash = "sha256:9c250883f9fd81d212e0952c92dbfcc96fc237f4b7c92f56ac81fd48460b3e5a"}, + {file = "mypy-1.13.0.tar.gz", hash = "sha256:0291a61b6fbf3e6673e3405cfcc0e7650bebc7939659fdca2702958038bd835e"}, ] [package.dependencies] @@ -1260,6 +1262,7 @@ typing-extensions = ">=4.6.0" [package.extras] dmypy = ["psutil (>=4.0)"] +faster-cache = ["orjson"] install-types = ["pip"] mypyc = ["setuptools (>=50)"] reports = ["lxml"] @@ -1303,27 +1306,35 @@ testing-docutils = ["pygments", "pytest (>=7,<8)", "pytest-param-files (>=0.3.4, [[package]] name = "nh3" -version = "0.2.18" +version = "0.2.19" description = "Python bindings to the ammonia HTML sanitization library." optional = false python-versions = "*" files = [ - {file = "nh3-0.2.18-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:14c5a72e9fe82aea5fe3072116ad4661af5cf8e8ff8fc5ad3450f123e4925e86"}, - {file = "nh3-0.2.18-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:7b7c2a3c9eb1a827d42539aa64091640bd275b81e097cd1d8d82ef91ffa2e811"}, - {file = "nh3-0.2.18-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42c64511469005058cd17cc1537578eac40ae9f7200bedcfd1fc1a05f4f8c200"}, - {file = "nh3-0.2.18-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0411beb0589eacb6734f28d5497ca2ed379eafab8ad8c84b31bb5c34072b7164"}, - {file = "nh3-0.2.18-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:5f36b271dae35c465ef5e9090e1fdaba4a60a56f0bb0ba03e0932a66f28b9189"}, - {file = "nh3-0.2.18-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:34c03fa78e328c691f982b7c03d4423bdfd7da69cd707fe572f544cf74ac23ad"}, - {file = "nh3-0.2.18-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:19aaba96e0f795bd0a6c56291495ff59364f4300d4a39b29a0abc9cb3774a84b"}, - {file = "nh3-0.2.18-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de3ceed6e661954871d6cd78b410213bdcb136f79aafe22aa7182e028b8c7307"}, - {file = "nh3-0.2.18-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6955369e4d9f48f41e3f238a9e60f9410645db7e07435e62c6a9ea6135a4907f"}, - {file = "nh3-0.2.18-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f0eca9ca8628dbb4e916ae2491d72957fdd35f7a5d326b7032a345f111ac07fe"}, - {file = "nh3-0.2.18-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:3a157ab149e591bb638a55c8c6bcb8cdb559c8b12c13a8affaba6cedfe51713a"}, - {file = "nh3-0.2.18-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:c8b3a1cebcba9b3669ed1a84cc65bf005728d2f0bc1ed2a6594a992e817f3a50"}, - {file = "nh3-0.2.18-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:36c95d4b70530b320b365659bb5034341316e6a9b30f0b25fa9c9eff4c27a204"}, - {file = "nh3-0.2.18-cp37-abi3-win32.whl", hash = "sha256:a7f1b5b2c15866f2db413a3649a8fe4fd7b428ae58be2c0f6bca5eefd53ca2be"}, - {file = "nh3-0.2.18-cp37-abi3-win_amd64.whl", hash = "sha256:8ce0f819d2f1933953fca255db2471ad58184a60508f03e6285e5114b6254844"}, - {file = "nh3-0.2.18.tar.gz", hash = "sha256:94a166927e53972a9698af9542ace4e38b9de50c34352b962f4d9a7d4c927af4"}, + {file = "nh3-0.2.19-cp313-cp313t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:ec9c8bf86e397cb88c560361f60fdce478b5edb8b93f04ead419b72fbe937ea6"}, + {file = "nh3-0.2.19-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d0adf00e2b2026fa10a42537b60d161e516f206781c7515e4e97e09f72a8c5d0"}, + {file = "nh3-0.2.19-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3805161c4e12088bd74752ba69630e915bc30fe666034f47217a2f16b16efc37"}, + {file = "nh3-0.2.19-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e3dedd7858a21312f7675841529941035a2ac91057db13402c8fe907aa19205a"}, + {file = "nh3-0.2.19-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:0b6820fc64f2ff7ef3e7253a093c946a87865c877b3889149a6d21d322ed8dbd"}, + {file = "nh3-0.2.19-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:833b3b5f1783ce95834a13030300cea00cbdfd64ea29260d01af9c4821da0aa9"}, + {file = "nh3-0.2.19-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5d4f5e2189861b352b73acb803b5f4bb409c2f36275d22717e27d4e0c217ae55"}, + {file = "nh3-0.2.19-cp313-cp313t-win32.whl", hash = "sha256:2b926f179eb4bce72b651bfdf76f8aa05d167b2b72bc2f3657fd319f40232adc"}, + {file = "nh3-0.2.19-cp313-cp313t-win_amd64.whl", hash = "sha256:ac536a4b5c073fdadd8f5f4889adabe1cbdae55305366fb870723c96ca7f49c3"}, + {file = "nh3-0.2.19-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:c2e3f0d18cc101132fe10ab7ef5c4f41411297e639e23b64b5e888ccaad63f41"}, + {file = "nh3-0.2.19-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:11270b16c1b012677e3e2dd166c1aa273388776bf99a3e3677179db5097ee16a"}, + {file = "nh3-0.2.19-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fc483dd8d20f8f8c010783a25a84db3bebeadced92d24d34b40d687f8043ac69"}, + {file = "nh3-0.2.19-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:d53a4577b6123ca1d7e8483fad3e13cb7eda28913d516bd0a648c1a473aa21a9"}, + {file = "nh3-0.2.19-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fdb20740d24ab9f2a1341458a00a11205294e97e905de060eeab1ceca020c09c"}, + {file = "nh3-0.2.19-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d8325d51e47cb5b11f649d55e626d56c76041ba508cd59e0cb1cf687cc7612f1"}, + {file = "nh3-0.2.19-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8eb7affc590e542fa7981ef508cd1644f62176bcd10d4429890fc629b47f0bc"}, + {file = "nh3-0.2.19-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2eb021804e9df1761abeb844bb86648d77aa118a663c82f50ea04110d87ed707"}, + {file = "nh3-0.2.19-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a7b928862daddb29805a1010a0282f77f4b8b238a37b5f76bc6c0d16d930fd22"}, + {file = "nh3-0.2.19-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:ed06ed78f6b69d57463b46a04f68f270605301e69d80756a8adf7519002de57d"}, + {file = "nh3-0.2.19-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:df8eac98fec80bd6f5fd0ae27a65de14f1e1a65a76d8e2237eb695f9cd1121d9"}, + {file = "nh3-0.2.19-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:00810cd5275f5c3f44b9eb0e521d1a841ee2f8023622de39ffc7d88bd533d8e0"}, + {file = "nh3-0.2.19-cp38-abi3-win32.whl", hash = "sha256:7e98621856b0a911c21faa5eef8f8ea3e691526c2433f9afc2be713cb6fbdb48"}, + {file = "nh3-0.2.19-cp38-abi3-win_amd64.whl", hash = "sha256:75c7cafb840f24430b009f7368945cb5ca88b2b54bb384ebfba495f16bc9c121"}, + {file = "nh3-0.2.19.tar.gz", hash = "sha256:790056b54c068ff8dceb443eaefb696b84beff58cca6c07afd754d17692a4804"}, ] [[package]] @@ -1339,13 +1350,13 @@ files = [ [[package]] name = "packageurl-python" -version = "0.15.6" +version = "0.16.0" description = "A purl aka. Package URL parser and builder" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "packageurl_python-0.15.6-py3-none-any.whl", hash = "sha256:a40210652c89022772a6c8340d6066f7d5dc67132141e5284a4db7a27d0a8ab0"}, - {file = "packageurl_python-0.15.6.tar.gz", hash = "sha256:cbc89afd15d5f4d05db4f1b61297e5b97a43f61f28799f6d282aff467ed2ee96"}, + {file = "packageurl_python-0.16.0-py3-none-any.whl", hash = "sha256:5c3872638b177b0f1cf01c3673017b7b27ebee485693ae12a8bed70fa7fa7c35"}, + {file = "packageurl_python-0.16.0.tar.gz", hash = "sha256:69e3bf8a3932fe9c2400f56aaeb9f86911ecee2f9398dbe1b58ec34340be365d"}, ] [package.extras] @@ -1356,13 +1367,13 @@ test = ["pytest"] [[package]] name = "packaging" -version = "24.1" +version = "24.2" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" files = [ - {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, - {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, + {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, + {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, ] [[package]] @@ -1378,13 +1389,13 @@ files = [ [[package]] name = "pip" -version = "24.2" +version = "24.3.1" description = "The PyPA recommended tool for installing Python packages." optional = false python-versions = ">=3.8" files = [ - {file = "pip-24.2-py3-none-any.whl", hash = "sha256:2cd581cf58ab7fcfca4ce8efa6dcacd0de5bf8d0a3eb9ec927e07405f4d9e2a2"}, - {file = "pip-24.2.tar.gz", hash = "sha256:5b5e490b5e9cb275c879595064adce9ebd31b854e3e803740b72f9ccf34a45b8"}, + {file = "pip-24.3.1-py3-none-any.whl", hash = "sha256:3790624780082365f47549d032f3770eeb2b1e8bd1f7b2e02dace1afa361b4ed"}, + {file = "pip-24.3.1.tar.gz", hash = "sha256:ebcb60557f2aefabc2e0f918751cd24ea0d56d8ec5445fe1807f1d2109660b99"}, ] [[package]] @@ -1450,13 +1461,13 @@ testing = ["aboutcode-toolkit (>=6.0.0)", "black", "pytest (>=6,!=7.0.0)", "pyte [[package]] name = "pkginfo" -version = "1.11.2" +version = "1.12.0" description = "Query metadata from sdists / bdists / installed packages." optional = false python-versions = ">=3.8" files = [ - {file = "pkginfo-1.11.2-py3-none-any.whl", hash = "sha256:9ec518eefccd159de7ed45386a6bb4c6ca5fa2cb3bd9b71154fae44f6f1b36a3"}, - {file = "pkginfo-1.11.2.tar.gz", hash = "sha256:c6bc916b8298d159e31f2c216e35ee5b86da7da18874f879798d0a1983537c86"}, + {file = "pkginfo-1.12.0-py3-none-any.whl", hash = "sha256:dcd589c9be4da8973eceffa247733c144812759aa67eaf4bbf97016a02f39088"}, + {file = "pkginfo-1.12.0.tar.gz", hash = "sha256:8ad91a0445a036782b9366ef8b8c2c50291f83a553478ba8580c73d3215700cf"}, ] [package.extras] @@ -1988,13 +1999,13 @@ idna2008 = ["idna"] [[package]] name = "rich" -version = "13.9.2" +version = "13.9.4" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" optional = false python-versions = ">=3.8.0" files = [ - {file = "rich-13.9.2-py3-none-any.whl", hash = "sha256:8c82a3d3f8dcfe9e734771313e606b39d8247bb6b826e196f4914b333b743cf1"}, - {file = "rich-13.9.2.tar.gz", hash = "sha256:51a2c62057461aaf7152b4d611168f93a9fc73068f8ded2790f29fe2b5366d0c"}, + {file = "rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90"}, + {file = "rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098"}, ] [package.dependencies] @@ -2007,28 +2018,29 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"] [[package]] name = "ruff" -version = "0.4.10" +version = "0.8.2" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" files = [ - {file = "ruff-0.4.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5c2c4d0859305ac5a16310eec40e4e9a9dec5dcdfbe92697acd99624e8638dac"}, - {file = "ruff-0.4.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a79489607d1495685cdd911a323a35871abfb7a95d4f98fc6f85e799227ac46e"}, - {file = "ruff-0.4.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b1dd1681dfa90a41b8376a61af05cc4dc5ff32c8f14f5fe20dba9ff5deb80cd6"}, - {file = "ruff-0.4.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c75c53bb79d71310dc79fb69eb4902fba804a81f374bc86a9b117a8d077a1784"}, - {file = "ruff-0.4.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:18238c80ee3d9100d3535d8eb15a59c4a0753b45cc55f8bf38f38d6a597b9739"}, - {file = "ruff-0.4.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:d8f71885bce242da344989cae08e263de29752f094233f932d4f5cfb4ef36a81"}, - {file = "ruff-0.4.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:330421543bd3222cdfec481e8ff3460e8702ed1e58b494cf9d9e4bf90db52b9d"}, - {file = "ruff-0.4.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e9b6fb3a37b772628415b00c4fc892f97954275394ed611056a4b8a2631365e"}, - {file = "ruff-0.4.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f54c481b39a762d48f64d97351048e842861c6662d63ec599f67d515cb417f6"}, - {file = "ruff-0.4.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:67fe086b433b965c22de0b4259ddfe6fa541c95bf418499bedb9ad5fb8d1c631"}, - {file = "ruff-0.4.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:acfaaab59543382085f9eb51f8e87bac26bf96b164839955f244d07125a982ef"}, - {file = "ruff-0.4.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:3cea07079962b2941244191569cf3a05541477286f5cafea638cd3aa94b56815"}, - {file = "ruff-0.4.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:338a64ef0748f8c3a80d7f05785930f7965d71ca260904a9321d13be24b79695"}, - {file = "ruff-0.4.10-py3-none-win32.whl", hash = "sha256:ffe3cd2f89cb54561c62e5fa20e8f182c0a444934bf430515a4b422f1ab7b7ca"}, - {file = "ruff-0.4.10-py3-none-win_amd64.whl", hash = "sha256:67f67cef43c55ffc8cc59e8e0b97e9e60b4837c8f21e8ab5ffd5d66e196e25f7"}, - {file = "ruff-0.4.10-py3-none-win_arm64.whl", hash = "sha256:dd1fcee327c20addac7916ca4e2653fbbf2e8388d8a6477ce5b4e986b68ae6c0"}, - {file = "ruff-0.4.10.tar.gz", hash = "sha256:3aa4f2bc388a30d346c56524f7cacca85945ba124945fe489952aadb6b5cd804"}, + {file = "ruff-0.8.2-py3-none-linux_armv6l.whl", hash = "sha256:c49ab4da37e7c457105aadfd2725e24305ff9bc908487a9bf8d548c6dad8bb3d"}, + {file = "ruff-0.8.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ec016beb69ac16be416c435828be702ee694c0d722505f9c1f35e1b9c0cc1bf5"}, + {file = "ruff-0.8.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f05cdf8d050b30e2ba55c9b09330b51f9f97d36d4673213679b965d25a785f3c"}, + {file = "ruff-0.8.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:60f578c11feb1d3d257b2fb043ddb47501ab4816e7e221fbb0077f0d5d4e7b6f"}, + {file = "ruff-0.8.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cbd5cf9b0ae8f30eebc7b360171bd50f59ab29d39f06a670b3e4501a36ba5897"}, + {file = "ruff-0.8.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b402ddee3d777683de60ff76da801fa7e5e8a71038f57ee53e903afbcefdaa58"}, + {file = "ruff-0.8.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:705832cd7d85605cb7858d8a13d75993c8f3ef1397b0831289109e953d833d29"}, + {file = "ruff-0.8.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:32096b41aaf7a5cc095fa45b4167b890e4c8d3fd217603f3634c92a541de7248"}, + {file = "ruff-0.8.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e769083da9439508833cfc7c23e351e1809e67f47c50248250ce1ac52c21fb93"}, + {file = "ruff-0.8.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5fe716592ae8a376c2673fdfc1f5c0c193a6d0411f90a496863c99cd9e2ae25d"}, + {file = "ruff-0.8.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:81c148825277e737493242b44c5388a300584d73d5774defa9245aaef55448b0"}, + {file = "ruff-0.8.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d261d7850c8367704874847d95febc698a950bf061c9475d4a8b7689adc4f7fa"}, + {file = "ruff-0.8.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1ca4e3a87496dc07d2427b7dd7ffa88a1e597c28dad65ae6433ecb9f2e4f022f"}, + {file = "ruff-0.8.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:729850feed82ef2440aa27946ab39c18cb4a8889c1128a6d589ffa028ddcfc22"}, + {file = "ruff-0.8.2-py3-none-win32.whl", hash = "sha256:ac42caaa0411d6a7d9594363294416e0e48fc1279e1b0e948391695db2b3d5b1"}, + {file = "ruff-0.8.2-py3-none-win_amd64.whl", hash = "sha256:2aae99ec70abf43372612a838d97bfe77d45146254568d94926e8ed5bbb409ea"}, + {file = "ruff-0.8.2-py3-none-win_arm64.whl", hash = "sha256:fb88e2a506b70cfbc2de6fae6681c4f944f7dd5f2fe87233a7233d888bad73e8"}, + {file = "ruff-0.8.2.tar.gz", hash = "sha256:b84f4f414dda8ac7f75075c1fa0b905ac0ff25361f42e6d5da681a465e0f78e5"}, ] [[package]] @@ -2074,33 +2086,33 @@ files = [ [[package]] name = "setuptools" -version = "75.2.0" +version = "75.6.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "setuptools-75.2.0-py3-none-any.whl", hash = "sha256:a7fcb66f68b4d9e8e66b42f9876150a3371558f98fa32222ffaa5bced76406f8"}, - {file = "setuptools-75.2.0.tar.gz", hash = "sha256:753bb6ebf1f465a1912e19ed1d41f403a79173a9acf66a42e7e6aec45c3c16ec"}, + {file = "setuptools-75.6.0-py3-none-any.whl", hash = "sha256:ce74b49e8f7110f9bf04883b730f4765b774ef3ef28f722cce7c273d253aaf7d"}, + {file = "setuptools-75.6.0.tar.gz", hash = "sha256:8199222558df7c86216af4f84c30e9b34a61d8ba19366cc914424cdbd28252f6"}, ] [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)", "ruff (>=0.5.2)"] -core = ["importlib-metadata (>=6)", "importlib-resources (>=5.10.2)", "jaraco.collections", "jaraco.functools", "jaraco.text (>=3.7)", "more-itertools", "more-itertools (>=8.8)", "packaging", "packaging (>=24)", "platformdirs (>=2.6.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)", "ruff (>=0.7.0)"] +core = ["importlib_metadata (>=6)", "jaraco.collections", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] enabler = ["pytest-enabler (>=2.2)"] -test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] -type = ["importlib-metadata (>=7.0.2)", "jaraco.develop (>=7.21)", "mypy (==1.11.*)", "pytest-mypy"] +test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] +type = ["importlib_metadata (>=7.0.2)", "jaraco.develop (>=7.21)", "mypy (>=1.12,<1.14)", "pytest-mypy"] [[package]] name = "six" -version = "1.16.0" +version = "1.17.0" description = "Python 2 and 3 compatibility utilities" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" files = [ - {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, - {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, + {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, + {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, ] [[package]] @@ -2394,13 +2406,43 @@ files = [ [[package]] name = "tomli" -version = "2.0.2" +version = "2.2.1" description = "A lil' TOML parser" optional = false python-versions = ">=3.8" files = [ - {file = "tomli-2.0.2-py3-none-any.whl", hash = "sha256:2ebe24485c53d303f690b0ec092806a085f07af5a5aa1464f3931eec36caaa38"}, - {file = "tomli-2.0.2.tar.gz", hash = "sha256:d46d457a85337051c36524bc5349dd91b1877838e2979ac5ced3e710ed8a60ed"}, + {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, + {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"}, + {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"}, + {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"}, + {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"}, + {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"}, + {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"}, + {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"}, + {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"}, + {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"}, + {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"}, + {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"}, + {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, + {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, ] [[package]] @@ -2416,20 +2458,21 @@ files = [ [[package]] name = "tqdm" -version = "4.66.5" +version = "4.67.1" description = "Fast, Extensible Progress Meter" optional = false python-versions = ">=3.7" files = [ - {file = "tqdm-4.66.5-py3-none-any.whl", hash = "sha256:90279a3770753eafc9194a0364852159802111925aa30eb3f9d85b0e805ac7cd"}, - {file = "tqdm-4.66.5.tar.gz", hash = "sha256:e1020aef2e5096702d8a025ac7d16b1577279c9d63f8375b63083e9a5f0fcbad"}, + {file = "tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2"}, + {file = "tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2"}, ] [package.dependencies] colorama = {version = "*", markers = "platform_system == \"Windows\""} [package.extras] -dev = ["pytest (>=6)", "pytest-cov", "pytest-timeout", "pytest-xdist"] +dev = ["nbval", "pytest (>=6)", "pytest-asyncio (>=0.24)", "pytest-cov", "pytest-timeout"] +discord = ["requests"] notebook = ["ipywidgets (>=6)"] slack = ["slack-sdk"] telegram = ["requests"] @@ -2459,13 +2502,13 @@ urllib3 = ">=1.26.0" [[package]] name = "types-deprecated" -version = "1.2.9.20240311" +version = "1.2.15.20241117" description = "Typing stubs for Deprecated" optional = false python-versions = ">=3.8" files = [ - {file = "types-Deprecated-1.2.9.20240311.tar.gz", hash = "sha256:0680e89989a8142707de8103f15d182445a533c1047fd9b7e8c5459101e9b90a"}, - {file = "types_Deprecated-1.2.9.20240311-py3-none-any.whl", hash = "sha256:d7793aaf32ff8f7e49a8ac781de4872248e0694c4b75a7a8a186c51167463f9d"}, + {file = "types-Deprecated-1.2.15.20241117.tar.gz", hash = "sha256:924002c8b7fddec51ba4949788a702411a2e3636cd9b2a33abd8ee119701d77e"}, + {file = "types_Deprecated-1.2.15.20241117-py3-none-any.whl", hash = "sha256:a0cc5e39f769fc54089fd8e005416b55d74aa03f6964d2ed1a0b0b2e28751884"}, ] [[package]] @@ -2512,13 +2555,13 @@ zstd = ["zstandard (>=0.18.0)"] [[package]] name = "virtualenv" -version = "20.26.6" +version = "20.28.0" description = "Virtual Python Environment builder" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "virtualenv-20.26.6-py3-none-any.whl", hash = "sha256:7345cc5b25405607a624d8418154577459c3e0277f5466dd79c49d5e492995f2"}, - {file = "virtualenv-20.26.6.tar.gz", hash = "sha256:280aede09a2a5c317e409a00102e7077c6432c5a38f0ef938e643805a7ad2c48"}, + {file = "virtualenv-20.28.0-py3-none-any.whl", hash = "sha256:23eae1b4516ecd610481eda647f3a7c09aea295055337331bb4e6892ecce47b0"}, + {file = "virtualenv-20.28.0.tar.gz", hash = "sha256:2c9c3262bb8e7b87ea801d715fae4495e6032450c71d2309be9550e7364049aa"}, ] [package.dependencies] @@ -2543,13 +2586,13 @@ files = [ [[package]] name = "wheel" -version = "0.44.0" +version = "0.45.1" description = "A built-package format for Python" optional = false python-versions = ">=3.8" files = [ - {file = "wheel-0.44.0-py3-none-any.whl", hash = "sha256:2376a90c98cc337d18623527a97c31797bd02bad0033d41547043a1cbfbe448f"}, - {file = "wheel-0.44.0.tar.gz", hash = "sha256:a29c3f2817e95ab89aa4660681ad547c0e9547f20e75b0562fe7723c9a2a9d49"}, + {file = "wheel-0.45.1-py3-none-any.whl", hash = "sha256:708e7481cc80179af0e556bbf0cc00b8444c7321e2700b8d8580231d13017248"}, + {file = "wheel-0.45.1.tar.gz", hash = "sha256:661e1abd9198507b1409a20c02106d9670b2576e916d58f520316666abca6729"}, ] [package.extras] @@ -2557,92 +2600,87 @@ test = ["pytest (>=6.0.0)", "setuptools (>=65)"] [[package]] name = "wrapt" -version = "1.16.0" +version = "1.17.0" description = "Module for decorators, wrappers and monkey patching." optional = false -python-versions = ">=3.6" +python-versions = ">=3.8" files = [ - {file = "wrapt-1.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ffa565331890b90056c01db69c0fe634a776f8019c143a5ae265f9c6bc4bd6d4"}, - {file = "wrapt-1.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e4fdb9275308292e880dcbeb12546df7f3e0f96c6b41197e0cf37d2826359020"}, - {file = "wrapt-1.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb2dee3874a500de01c93d5c71415fcaef1d858370d405824783e7a8ef5db440"}, - {file = "wrapt-1.16.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2a88e6010048489cda82b1326889ec075a8c856c2e6a256072b28eaee3ccf487"}, - {file = "wrapt-1.16.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac83a914ebaf589b69f7d0a1277602ff494e21f4c2f743313414378f8f50a4cf"}, - {file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:73aa7d98215d39b8455f103de64391cb79dfcad601701a3aa0dddacf74911d72"}, - {file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:807cc8543a477ab7422f1120a217054f958a66ef7314f76dd9e77d3f02cdccd0"}, - {file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bf5703fdeb350e36885f2875d853ce13172ae281c56e509f4e6eca049bdfb136"}, - {file = "wrapt-1.16.0-cp310-cp310-win32.whl", hash = "sha256:f6b2d0c6703c988d334f297aa5df18c45e97b0af3679bb75059e0e0bd8b1069d"}, - {file = "wrapt-1.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:decbfa2f618fa8ed81c95ee18a387ff973143c656ef800c9f24fb7e9c16054e2"}, - {file = "wrapt-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a5db485fe2de4403f13fafdc231b0dbae5eca4359232d2efc79025527375b09"}, - {file = "wrapt-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:75ea7d0ee2a15733684badb16de6794894ed9c55aa5e9903260922f0482e687d"}, - {file = "wrapt-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a452f9ca3e3267cd4d0fcf2edd0d035b1934ac2bd7e0e57ac91ad6b95c0c6389"}, - {file = "wrapt-1.16.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:43aa59eadec7890d9958748db829df269f0368521ba6dc68cc172d5d03ed8060"}, - {file = "wrapt-1.16.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72554a23c78a8e7aa02abbd699d129eead8b147a23c56e08d08dfc29cfdddca1"}, - {file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d2efee35b4b0a347e0d99d28e884dfd82797852d62fcd7ebdeee26f3ceb72cf3"}, - {file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:6dcfcffe73710be01d90cae08c3e548d90932d37b39ef83969ae135d36ef3956"}, - {file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:eb6e651000a19c96f452c85132811d25e9264d836951022d6e81df2fff38337d"}, - {file = "wrapt-1.16.0-cp311-cp311-win32.whl", hash = "sha256:66027d667efe95cc4fa945af59f92c5a02c6f5bb6012bff9e60542c74c75c362"}, - {file = "wrapt-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:aefbc4cb0a54f91af643660a0a150ce2c090d3652cf4052a5397fb2de549cd89"}, - {file = "wrapt-1.16.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5eb404d89131ec9b4f748fa5cfb5346802e5ee8836f57d516576e61f304f3b7b"}, - {file = "wrapt-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9090c9e676d5236a6948330e83cb89969f433b1943a558968f659ead07cb3b36"}, - {file = "wrapt-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94265b00870aa407bd0cbcfd536f17ecde43b94fb8d228560a1e9d3041462d73"}, - {file = "wrapt-1.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2058f813d4f2b5e3a9eb2eb3faf8f1d99b81c3e51aeda4b168406443e8ba809"}, - {file = "wrapt-1.16.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98b5e1f498a8ca1858a1cdbffb023bfd954da4e3fa2c0cb5853d40014557248b"}, - {file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:14d7dc606219cdd7405133c713f2c218d4252f2a469003f8c46bb92d5d095d81"}, - {file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:49aac49dc4782cb04f58986e81ea0b4768e4ff197b57324dcbd7699c5dfb40b9"}, - {file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:418abb18146475c310d7a6dc71143d6f7adec5b004ac9ce08dc7a34e2babdc5c"}, - {file = "wrapt-1.16.0-cp312-cp312-win32.whl", hash = "sha256:685f568fa5e627e93f3b52fda002c7ed2fa1800b50ce51f6ed1d572d8ab3e7fc"}, - {file = "wrapt-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:dcdba5c86e368442528f7060039eda390cc4091bfd1dca41e8046af7c910dda8"}, - {file = "wrapt-1.16.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:d462f28826f4657968ae51d2181a074dfe03c200d6131690b7d65d55b0f360f8"}, - {file = "wrapt-1.16.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a33a747400b94b6d6b8a165e4480264a64a78c8a4c734b62136062e9a248dd39"}, - {file = "wrapt-1.16.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3646eefa23daeba62643a58aac816945cadc0afaf21800a1421eeba5f6cfb9c"}, - {file = "wrapt-1.16.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ebf019be5c09d400cf7b024aa52b1f3aeebeff51550d007e92c3c1c4afc2a40"}, - {file = "wrapt-1.16.0-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:0d2691979e93d06a95a26257adb7bfd0c93818e89b1406f5a28f36e0d8c1e1fc"}, - {file = "wrapt-1.16.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:1acd723ee2a8826f3d53910255643e33673e1d11db84ce5880675954183ec47e"}, - {file = "wrapt-1.16.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:bc57efac2da352a51cc4658878a68d2b1b67dbe9d33c36cb826ca449d80a8465"}, - {file = "wrapt-1.16.0-cp36-cp36m-win32.whl", hash = "sha256:da4813f751142436b075ed7aa012a8778aa43a99f7b36afe9b742d3ed8bdc95e"}, - {file = "wrapt-1.16.0-cp36-cp36m-win_amd64.whl", hash = "sha256:6f6eac2360f2d543cc875a0e5efd413b6cbd483cb3ad7ebf888884a6e0d2e966"}, - {file = "wrapt-1.16.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a0ea261ce52b5952bf669684a251a66df239ec6d441ccb59ec7afa882265d593"}, - {file = "wrapt-1.16.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bd2d7ff69a2cac767fbf7a2b206add2e9a210e57947dd7ce03e25d03d2de292"}, - {file = "wrapt-1.16.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9159485323798c8dc530a224bd3ffcf76659319ccc7bbd52e01e73bd0241a0c5"}, - {file = "wrapt-1.16.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a86373cf37cd7764f2201b76496aba58a52e76dedfaa698ef9e9688bfd9e41cf"}, - {file = "wrapt-1.16.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:73870c364c11f03ed072dda68ff7aea6d2a3a5c3fe250d917a429c7432e15228"}, - {file = "wrapt-1.16.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b935ae30c6e7400022b50f8d359c03ed233d45b725cfdd299462f41ee5ffba6f"}, - {file = "wrapt-1.16.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:db98ad84a55eb09b3c32a96c576476777e87c520a34e2519d3e59c44710c002c"}, - {file = "wrapt-1.16.0-cp37-cp37m-win32.whl", hash = "sha256:9153ed35fc5e4fa3b2fe97bddaa7cbec0ed22412b85bcdaf54aeba92ea37428c"}, - {file = "wrapt-1.16.0-cp37-cp37m-win_amd64.whl", hash = "sha256:66dfbaa7cfa3eb707bbfcd46dab2bc6207b005cbc9caa2199bcbc81d95071a00"}, - {file = "wrapt-1.16.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1dd50a2696ff89f57bd8847647a1c363b687d3d796dc30d4dd4a9d1689a706f0"}, - {file = "wrapt-1.16.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:44a2754372e32ab315734c6c73b24351d06e77ffff6ae27d2ecf14cf3d229202"}, - {file = "wrapt-1.16.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e9723528b9f787dc59168369e42ae1c3b0d3fadb2f1a71de14531d321ee05b0"}, - {file = "wrapt-1.16.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dbed418ba5c3dce92619656802cc5355cb679e58d0d89b50f116e4a9d5a9603e"}, - {file = "wrapt-1.16.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:941988b89b4fd6b41c3f0bfb20e92bd23746579736b7343283297c4c8cbae68f"}, - {file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6a42cd0cfa8ffc1915aef79cb4284f6383d8a3e9dcca70c445dcfdd639d51267"}, - {file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:1ca9b6085e4f866bd584fb135a041bfc32cab916e69f714a7d1d397f8c4891ca"}, - {file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:d5e49454f19ef621089e204f862388d29e6e8d8b162efce05208913dde5b9ad6"}, - {file = "wrapt-1.16.0-cp38-cp38-win32.whl", hash = "sha256:c31f72b1b6624c9d863fc095da460802f43a7c6868c5dda140f51da24fd47d7b"}, - {file = "wrapt-1.16.0-cp38-cp38-win_amd64.whl", hash = "sha256:490b0ee15c1a55be9c1bd8609b8cecd60e325f0575fc98f50058eae366e01f41"}, - {file = "wrapt-1.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9b201ae332c3637a42f02d1045e1d0cccfdc41f1f2f801dafbaa7e9b4797bfc2"}, - {file = "wrapt-1.16.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2076fad65c6736184e77d7d4729b63a6d1ae0b70da4868adeec40989858eb3fb"}, - {file = "wrapt-1.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5cd603b575ebceca7da5a3a251e69561bec509e0b46e4993e1cac402b7247b8"}, - {file = "wrapt-1.16.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b47cfad9e9bbbed2339081f4e346c93ecd7ab504299403320bf85f7f85c7d46c"}, - {file = "wrapt-1.16.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8212564d49c50eb4565e502814f694e240c55551a5f1bc841d4fcaabb0a9b8a"}, - {file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:5f15814a33e42b04e3de432e573aa557f9f0f56458745c2074952f564c50e664"}, - {file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:db2e408d983b0e61e238cf579c09ef7020560441906ca990fe8412153e3b291f"}, - {file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:edfad1d29c73f9b863ebe7082ae9321374ccb10879eeabc84ba3b69f2579d537"}, - {file = "wrapt-1.16.0-cp39-cp39-win32.whl", hash = "sha256:ed867c42c268f876097248e05b6117a65bcd1e63b779e916fe2e33cd6fd0d3c3"}, - {file = "wrapt-1.16.0-cp39-cp39-win_amd64.whl", hash = "sha256:eb1b046be06b0fce7249f1d025cd359b4b80fc1c3e24ad9eca33e0dcdb2e4a35"}, - {file = "wrapt-1.16.0-py3-none-any.whl", hash = "sha256:6906c4100a8fcbf2fa735f6059214bb13b97f75b1a61777fcf6432121ef12ef1"}, - {file = "wrapt-1.16.0.tar.gz", hash = "sha256:5f370f952971e7d17c7d1ead40e49f32345a7f7a5373571ef44d800d06b1899d"}, + {file = "wrapt-1.17.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a0c23b8319848426f305f9cb0c98a6e32ee68a36264f45948ccf8e7d2b941f8"}, + {file = "wrapt-1.17.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b1ca5f060e205f72bec57faae5bd817a1560fcfc4af03f414b08fa29106b7e2d"}, + {file = "wrapt-1.17.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e185ec6060e301a7e5f8461c86fb3640a7beb1a0f0208ffde7a65ec4074931df"}, + {file = "wrapt-1.17.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb90765dd91aed05b53cd7a87bd7f5c188fcd95960914bae0d32c5e7f899719d"}, + {file = "wrapt-1.17.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:879591c2b5ab0a7184258274c42a126b74a2c3d5a329df16d69f9cee07bba6ea"}, + {file = "wrapt-1.17.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fce6fee67c318fdfb7f285c29a82d84782ae2579c0e1b385b7f36c6e8074fffb"}, + {file = "wrapt-1.17.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0698d3a86f68abc894d537887b9bbf84d29bcfbc759e23f4644be27acf6da301"}, + {file = "wrapt-1.17.0-cp310-cp310-win32.whl", hash = "sha256:69d093792dc34a9c4c8a70e4973a3361c7a7578e9cd86961b2bbf38ca71e4e22"}, + {file = "wrapt-1.17.0-cp310-cp310-win_amd64.whl", hash = "sha256:f28b29dc158ca5d6ac396c8e0a2ef45c4e97bb7e65522bfc04c989e6fe814575"}, + {file = "wrapt-1.17.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:74bf625b1b4caaa7bad51d9003f8b07a468a704e0644a700e936c357c17dd45a"}, + {file = "wrapt-1.17.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0f2a28eb35cf99d5f5bd12f5dd44a0f41d206db226535b37b0c60e9da162c3ed"}, + {file = "wrapt-1.17.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:81b1289e99cf4bad07c23393ab447e5e96db0ab50974a280f7954b071d41b489"}, + {file = "wrapt-1.17.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9f2939cd4a2a52ca32bc0b359015718472d7f6de870760342e7ba295be9ebaf9"}, + {file = "wrapt-1.17.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6a9653131bda68a1f029c52157fd81e11f07d485df55410401f745007bd6d339"}, + {file = "wrapt-1.17.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4e4b4385363de9052dac1a67bfb535c376f3d19c238b5f36bddc95efae15e12d"}, + {file = "wrapt-1.17.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bdf62d25234290db1837875d4dceb2151e4ea7f9fff2ed41c0fde23ed542eb5b"}, + {file = "wrapt-1.17.0-cp311-cp311-win32.whl", hash = "sha256:5d8fd17635b262448ab8f99230fe4dac991af1dabdbb92f7a70a6afac8a7e346"}, + {file = "wrapt-1.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:92a3d214d5e53cb1db8b015f30d544bc9d3f7179a05feb8f16df713cecc2620a"}, + {file = "wrapt-1.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:89fc28495896097622c3fc238915c79365dd0ede02f9a82ce436b13bd0ab7569"}, + {file = "wrapt-1.17.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:875d240fdbdbe9e11f9831901fb8719da0bd4e6131f83aa9f69b96d18fae7504"}, + {file = "wrapt-1.17.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e5ed16d95fd142e9c72b6c10b06514ad30e846a0d0917ab406186541fe68b451"}, + {file = "wrapt-1.17.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18b956061b8db634120b58f668592a772e87e2e78bc1f6a906cfcaa0cc7991c1"}, + {file = "wrapt-1.17.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:daba396199399ccabafbfc509037ac635a6bc18510ad1add8fd16d4739cdd106"}, + {file = "wrapt-1.17.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4d63f4d446e10ad19ed01188d6c1e1bb134cde8c18b0aa2acfd973d41fcc5ada"}, + {file = "wrapt-1.17.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8a5e7cc39a45fc430af1aefc4d77ee6bad72c5bcdb1322cfde852c15192b8bd4"}, + {file = "wrapt-1.17.0-cp312-cp312-win32.whl", hash = "sha256:0a0a1a1ec28b641f2a3a2c35cbe86c00051c04fffcfcc577ffcdd707df3f8635"}, + {file = "wrapt-1.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:3c34f6896a01b84bab196f7119770fd8466c8ae3dfa73c59c0bb281e7b588ce7"}, + {file = "wrapt-1.17.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:714c12485aa52efbc0fc0ade1e9ab3a70343db82627f90f2ecbc898fdf0bb181"}, + {file = "wrapt-1.17.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da427d311782324a376cacb47c1a4adc43f99fd9d996ffc1b3e8529c4074d393"}, + {file = "wrapt-1.17.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ba1739fb38441a27a676f4de4123d3e858e494fac05868b7a281c0a383c098f4"}, + {file = "wrapt-1.17.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e711fc1acc7468463bc084d1b68561e40d1eaa135d8c509a65dd534403d83d7b"}, + {file = "wrapt-1.17.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:140ea00c87fafc42739bd74a94a5a9003f8e72c27c47cd4f61d8e05e6dec8721"}, + {file = "wrapt-1.17.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:73a96fd11d2b2e77d623a7f26e004cc31f131a365add1ce1ce9a19e55a1eef90"}, + {file = "wrapt-1.17.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0b48554952f0f387984da81ccfa73b62e52817a4386d070c75e4db7d43a28c4a"}, + {file = "wrapt-1.17.0-cp313-cp313-win32.whl", hash = "sha256:498fec8da10e3e62edd1e7368f4b24aa362ac0ad931e678332d1b209aec93045"}, + {file = "wrapt-1.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:fd136bb85f4568fffca995bd3c8d52080b1e5b225dbf1c2b17b66b4c5fa02838"}, + {file = "wrapt-1.17.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:17fcf043d0b4724858f25b8826c36e08f9fb2e475410bece0ec44a22d533da9b"}, + {file = "wrapt-1.17.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4a557d97f12813dc5e18dad9fa765ae44ddd56a672bb5de4825527c847d6379"}, + {file = "wrapt-1.17.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0229b247b0fc7dee0d36176cbb79dbaf2a9eb7ecc50ec3121f40ef443155fb1d"}, + {file = "wrapt-1.17.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8425cfce27b8b20c9b89d77fb50e368d8306a90bf2b6eef2cdf5cd5083adf83f"}, + {file = "wrapt-1.17.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9c900108df470060174108012de06d45f514aa4ec21a191e7ab42988ff42a86c"}, + {file = "wrapt-1.17.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:4e547b447073fc0dbfcbff15154c1be8823d10dab4ad401bdb1575e3fdedff1b"}, + {file = "wrapt-1.17.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:914f66f3b6fc7b915d46c1cc424bc2441841083de01b90f9e81109c9759e43ab"}, + {file = "wrapt-1.17.0-cp313-cp313t-win32.whl", hash = "sha256:a4192b45dff127c7d69b3bdfb4d3e47b64179a0b9900b6351859f3001397dabf"}, + {file = "wrapt-1.17.0-cp313-cp313t-win_amd64.whl", hash = "sha256:4f643df3d4419ea3f856c5c3f40fec1d65ea2e89ec812c83f7767c8730f9827a"}, + {file = "wrapt-1.17.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:69c40d4655e078ede067a7095544bcec5a963566e17503e75a3a3e0fe2803b13"}, + {file = "wrapt-1.17.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f495b6754358979379f84534f8dd7a43ff8cff2558dcdea4a148a6e713a758f"}, + {file = "wrapt-1.17.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:baa7ef4e0886a6f482e00d1d5bcd37c201b383f1d314643dfb0367169f94f04c"}, + {file = "wrapt-1.17.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8fc931382e56627ec4acb01e09ce66e5c03c384ca52606111cee50d931a342d"}, + {file = "wrapt-1.17.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:8f8909cdb9f1b237786c09a810e24ee5e15ef17019f7cecb207ce205b9b5fcce"}, + {file = "wrapt-1.17.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:ad47b095f0bdc5585bced35bd088cbfe4177236c7df9984b3cc46b391cc60627"}, + {file = "wrapt-1.17.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:948a9bd0fb2c5120457b07e59c8d7210cbc8703243225dbd78f4dfc13c8d2d1f"}, + {file = "wrapt-1.17.0-cp38-cp38-win32.whl", hash = "sha256:5ae271862b2142f4bc687bdbfcc942e2473a89999a54231aa1c2c676e28f29ea"}, + {file = "wrapt-1.17.0-cp38-cp38-win_amd64.whl", hash = "sha256:f335579a1b485c834849e9075191c9898e0731af45705c2ebf70e0cd5d58beed"}, + {file = "wrapt-1.17.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d751300b94e35b6016d4b1e7d0e7bbc3b5e1751e2405ef908316c2a9024008a1"}, + {file = "wrapt-1.17.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7264cbb4a18dc4acfd73b63e4bcfec9c9802614572025bdd44d0721983fc1d9c"}, + {file = "wrapt-1.17.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:33539c6f5b96cf0b1105a0ff4cf5db9332e773bb521cc804a90e58dc49b10578"}, + {file = "wrapt-1.17.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c30970bdee1cad6a8da2044febd824ef6dc4cc0b19e39af3085c763fdec7de33"}, + {file = "wrapt-1.17.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:bc7f729a72b16ee21795a943f85c6244971724819819a41ddbaeb691b2dd85ad"}, + {file = "wrapt-1.17.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:6ff02a91c4fc9b6a94e1c9c20f62ea06a7e375f42fe57587f004d1078ac86ca9"}, + {file = "wrapt-1.17.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2dfb7cff84e72e7bf975b06b4989477873dcf160b2fd89959c629535df53d4e0"}, + {file = "wrapt-1.17.0-cp39-cp39-win32.whl", hash = "sha256:2399408ac33ffd5b200480ee858baa58d77dd30e0dd0cab6a8a9547135f30a88"}, + {file = "wrapt-1.17.0-cp39-cp39-win_amd64.whl", hash = "sha256:4f763a29ee6a20c529496a20a7bcb16a73de27f5da6a843249c7047daf135977"}, + {file = "wrapt-1.17.0-py3-none-any.whl", hash = "sha256:d2c63b93548eda58abf5188e505ffed0229bf675f7c3090f8e36ad55b8cbc371"}, + {file = "wrapt-1.17.0.tar.gz", hash = "sha256:16187aa2317c731170a88ef35e8937ae0f533c402872c1ee5e6d079fcf320801"}, ] [[package]] name = "zipp" -version = "3.20.2" +version = "3.21.0" description = "Backport of pathlib-compatible object wrapper for zip files" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "zipp-3.20.2-py3-none-any.whl", hash = "sha256:a817ac80d6cf4b23bf7f2828b7cabf326f15a001bea8b1f9b49631780ba28350"}, - {file = "zipp-3.20.2.tar.gz", hash = "sha256:bc9eb26f4506fda01b81bcde0ca78103b6e62f991b381fec825435c836edbc29"}, + {file = "zipp-3.21.0-py3-none-any.whl", hash = "sha256:ac1bbe05fd2991f160ebce24ffbac5f6d11d83dc90891255885223d42b3cd931"}, + {file = "zipp-3.21.0.tar.gz", hash = "sha256:2c9958f6430a2040341a52eb608ed6dd93ef4392e02ffe219417c1b28b5dd1f4"}, ] [package.extras] @@ -2656,4 +2694,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "66e85df44cca4d3edccb50f730dfb4e9dccf93582e78fa0074dc9b47baa925e2" +content-hash = "726e13c507aac04c65d86a3ad85222f7c218eaed40689343445e9ff8574e8ec2" diff --git a/pyproject.toml b/pyproject.toml index bd391ae5..f5efc256 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ deprecated = "^1.2.14" [tool.poetry.group.dev.dependencies] pytest = "^7.2.0" -ruff = ">=0.1.6,<0.5.0" +ruff = ">=0.1.6,<=0.8.2" pip-audit = "^2.5.6" pytest-mock = "^3.11.1" mypy = "^1.5.1" @@ -93,8 +93,6 @@ lint.select = [ "RUF", # Ruff-specific rules ] lint.ignore = [ - "ANN101", # no type for self - "ANN102", # no type for cls "RET505", # allow else after return "SIM108", # allow if-else in place of ternary "E111", # indentation is not a multiple of four @@ -106,6 +104,7 @@ lint.ignore = [ "Q002", # bad quotes docstring "Q003", # avoidable escaped quotes "W191", # indentation contains tabs + "ERA001", # commented out code ] # Exclude a variety of commonly ignored directories. extend-exclude = [ @@ -113,31 +112,38 @@ extend-exclude = [ ".git", ".mypy_cache", ".ruff_cache", - + "tests/artifacts", ] # Assume Python 3.10. target-version = "py310" +[tool.ruff.lint.pylint] +max-args = 10 + [tool.ruff.lint.flake8-annotations] allow-star-arg-any = true suppress-none-returning = true [tool.ruff.lint.per-file-ignores] "src/algokit_utils/beta/*" = ["ERA001", "E501", "PLR0911"] -"path/to/file.py" = ["E402"] +"src/algokit_utils/applications/app_client.py" = ["SLF001"] +"src/algokit_utils/applications/app_factory.py" = ["SLF001"] "tests/clients/test_algorand_client.py" = ["ERA001"] +"src/algokit_utils/_legacy_v2/**/*" = ["E501"] +"tests/**/*" = ["PLR2004"] [tool.poe.tasks] docs = ["docs-html-only", "docs-md-only"] docs-md-only = "sphinx-build docs/source docs/markdown -b markdown" docs-html-only = "sphinx-build docs/source docs/html" +"tests/**/*" = ["PLR2004"] [tool.pytest.ini_options] pythonpath = ["src", "tests"] [tool.mypy] files = ["src", "tests"] -exclude = ["dist"] +exclude = ["dist", "tests/artifacts", "src/algokit_utils/_legacy_v2"] python_version = "3.10" warn_unused_ignores = true warn_redundant_casts = true diff --git a/src/algokit_utils/__init__.py b/src/algokit_utils/__init__.py index d89bad9b..5b3a1647 100644 --- a/src/algokit_utils/__init__.py +++ b/src/algokit_utils/__init__.py @@ -92,93 +92,93 @@ from algokit_utils.models.application import DELETABLE_TEMPLATE_NAME, UPDATABLE_TEMPLATE_NAME __all__ = [ - # ==== LEGACY V2 EXPORTS BEGIN ==== - "create_kmd_wallet_account", - "get_account_from_mnemonic", - "get_or_create_kmd_wallet_account", - "get_localnet_default_account", - "get_dispenser_account", - "get_kmd_wallet_account", - "get_account", - "UPDATABLE_TEMPLATE_NAME", "DELETABLE_TEMPLATE_NAME", + "DISPENSER_ACCESS_TOKEN_KEY", + "DISPENSER_REQUEST_TIMEOUT", "NOTE_PREFIX", - "DeploymentFailedError", - "AppReference", - "AppDeployMetaData", - "AppMetaData", - "AppLookup", - "get_creator_apps", - "replace_template_variables", + "UPDATABLE_TEMPLATE_NAME", "ABIArgsDict", "ABICallArgs", "ABICallArgsDict", "ABICreateCallArgs", "ABICreateCallArgsDict", "ABIMethod", + "ABITransactionResponse", + "Account", + "AlgoClientConfig", + "AppDeployMetaData", + "AppLookup", + "AppMetaData", + "AppReference", + "AppSpecStateDict", + "ApplicationClient", + "ApplicationSpecification", + "CallConfig", + "CommonCallParameters", + "CommonCallParametersDict", "CreateCallParameters", "CreateCallParametersDict", "CreateTransactionParameters", - "CommonCallParameters", - "CommonCallParametersDict", + "DefaultArgumentDict", + "DefaultArgumentType", "DeployCallArgs", - "DeployCreateCallArgs", "DeployCallArgsDict", + "DeployCreateCallArgs", "DeployCreateCallArgsDict", + "DeployResponse", + "DeploymentFailedError", + "DispenserFundResponse", + "DispenserLimitResponse", + "EnsureBalanceParameters", + "EnsureFundedResponse", + "LogicError", + "MethodConfigDict", + "MethodHints", + "OnCompleteActionName", "OnCompleteCallParameters", "OnCompleteCallParametersDict", - "TransactionParameters", - "TransactionParametersDict", - "ApplicationClient", - "DeployResponse", - "OnUpdate", "OnSchemaBreak", + "OnUpdate", "OperationPerformed", + "PersistSourceMapInput", + "Program", "TemplateValueDict", "TemplateValueMapping", - "Program", - "execute_atc_with_logic_error", - "get_app_id_from_tx_id", - "get_next_version", - "get_sender_from_signer", - "num_extra_program_pages", - "AppSpecStateDict", - "ApplicationSpecification", - "CallConfig", - "DefaultArgumentDict", - "DefaultArgumentType", - "MethodConfigDict", - "OnCompleteActionName", - "MethodHints", - "LogicError", - "ABITransactionResponse", - "Account", + "TestNetDispenserApiClient", + "TransactionParameters", + "TransactionParametersDict", "TransactionResponse", - "AlgoClientConfig", + "TransferAssetParameters", + "TransferParameters", + # ==== LEGACY V2 EXPORTS BEGIN ==== + "create_kmd_wallet_account", + "ensure_funded", + "execute_atc_with_logic_error", + "get_account", + "get_account_from_mnemonic", "get_algod_client", "get_algonode_config", + "get_app_id_from_tx_id", + "get_creator_apps", "get_default_localnet_config", + "get_dispenser_account", "get_indexer_client", "get_kmd_client_from_algod_client", + "get_kmd_wallet_account", + "get_localnet_default_account", + "get_next_version", + "get_or_create_kmd_wallet_account", + "get_sender_from_signer", "is_localnet", "is_mainnet", "is_testnet", - "TestNetDispenserApiClient", - "DispenserFundResponse", - "DispenserLimitResponse", - "DISPENSER_ACCESS_TOKEN_KEY", - "DISPENSER_REQUEST_TIMEOUT", - "EnsureBalanceParameters", - "EnsureFundedResponse", - "TransferParameters", - "ensure_funded", - "transfer", - "TransferAssetParameters", - "transfer_asset", + "num_extra_program_pages", "opt_in", "opt_out", "persist_sourcemaps", - "PersistSourceMapInput", + "replace_template_variables", "simulate_and_persist_response", + "transfer", + "transfer_asset", # ==== LEGACY V2 EXPORTS END ==== ] diff --git a/src/algokit_utils/_debugging.py b/src/algokit_utils/_debugging.py index de5ed182..0b9f798f 100644 --- a/src/algokit_utils/_debugging.py +++ b/src/algokit_utils/_debugging.py @@ -143,7 +143,7 @@ def _write_to_file(path: Path, content: str) -> None: path.write_text(content) -def _build_avm_sourcemap( # noqa: PLR0913 +def _build_avm_sourcemap( *, app_name: str, file_name: str, @@ -201,7 +201,18 @@ def persist_sourcemaps( _upsert_debug_sourcemaps(sourcemaps, project_root) -def simulate_response(atc: AtomicTransactionComposer, algod_client: "AlgodClient") -> SimulateAtomicTransactionResponse: +def simulate_response( + atc: AtomicTransactionComposer, + algod_client: "AlgodClient", + allow_more_logs: bool | None = None, + allow_empty_signatures: bool | None = None, + allow_unnamed_resources: bool | None = None, + extra_opcode_budget: int | None = None, + exec_trace_config: SimulateTraceConfig | None = None, + round: int | None = None, # noqa: A002 TODO: revisit + skip_signatures: int | None = None, # noqa: ARG001 TODO: revisit + fix_signers: bool | None = None, # noqa: ARG001 TODO: revisit +) -> SimulateAtomicTransactionResponse: """ Simulate and fetch response for the given AtomicTransactionComposer and AlgodClient. @@ -221,13 +232,31 @@ def simulate_response(atc: AtomicTransactionComposer, algod_client: "AlgodClient trace_config = SimulateTraceConfig(enable=True, stack_change=True, scratch_change=True, state_change=True) simulate_request = SimulateRequest( - txn_groups=txn_group, allow_more_logs=True, allow_empty_signatures=True, exec_trace_config=trace_config + txn_groups=txn_group, + allow_more_logs=allow_more_logs or True, + round=round, + extra_opcode_budget=extra_opcode_budget or 0, + allow_unnamed_resources=allow_unnamed_resources or True, + allow_empty_signatures=allow_empty_signatures or True, + exec_trace_config=exec_trace_config or trace_config, ) + return atc.simulate(algod_client, simulate_request) -def simulate_and_persist_response( - atc: AtomicTransactionComposer, project_root: Path, algod_client: "AlgodClient", buffer_size_mb: float = 256 +def simulate_and_persist_response( # noqa: PLR0913 TODO: revisit + atc: AtomicTransactionComposer, + project_root: Path, + algod_client: "AlgodClient", + buffer_size_mb: float = 256, + allow_more_logs: bool | None = None, + allow_empty_signatures: bool | None = None, + allow_unnamed_resources: bool | None = None, + extra_opcode_budget: int | None = None, + exec_trace_config: SimulateTraceConfig | None = None, + round: int | None = None, # noqa: A002 TODO: revisit + skip_signatures: int | None = None, + fix_signers: bool | None = None, ) -> SimulateAtomicTransactionResponse: """ Simulates the atomic transactions using the provided `AtomicTransactionComposer` object and `AlgodClient` object, @@ -252,7 +281,18 @@ def simulate_and_persist_response( txn_with_sign.txn.last_valid_round = sp.last txn_with_sign.txn.genesis_hash = sp.gh - response = simulate_response(atc_to_simulate, algod_client) + response = simulate_response( + atc_to_simulate, + algod_client, + allow_more_logs, + allow_empty_signatures, + allow_unnamed_resources, + extra_opcode_budget, + exec_trace_config, + round, + skip_signatures, + fix_signers, + ) txn_results = response.simulate_response["txn-groups"] txn_types = [txn_result["txn-results"][0]["txn-result"]["txn"]["txn"]["type"] for txn_result in txn_results] diff --git a/src/algokit_utils/_legacy_v2/_ensure_funded.py b/src/algokit_utils/_legacy_v2/_ensure_funded.py index 2db90f36..99409b36 100644 --- a/src/algokit_utils/_legacy_v2/_ensure_funded.py +++ b/src/algokit_utils/_legacy_v2/_ensure_funded.py @@ -4,6 +4,7 @@ from algosdk.atomic_transaction_composer import AccountTransactionSigner from algosdk.transaction import SuggestedParams from algosdk.v2client.algod import AlgodClient +from typing_extensions import deprecated from algokit_utils._legacy_v2._transfer import TransferParameters, transfer from algokit_utils._legacy_v2.account import get_dispenser_account @@ -115,6 +116,10 @@ def _fund_using_transfer( return EnsureFundedResponse(transaction_id=transaction_id, amount=response.amt) +@deprecated( + "Use `algorand.account.ensure_funded()`, `algorand.account.ensure_funded_from_environment()`, " + "or `algorand.account.ensure_funded_from_testnet_dispenser_api()` instead" +) def ensure_funded( client: AlgodClient, parameters: EnsureBalanceParameters, diff --git a/src/algokit_utils/_legacy_v2/_transfer.py b/src/algokit_utils/_legacy_v2/_transfer.py index 6b59cd4c..28de779f 100644 --- a/src/algokit_utils/_legacy_v2/_transfer.py +++ b/src/algokit_utils/_legacy_v2/_transfer.py @@ -12,7 +12,7 @@ if TYPE_CHECKING: from algosdk.v2client.algod import AlgodClient -__all__ = ["TransferParameters", "transfer", "TransferAssetParameters", "transfer_asset"] +__all__ = ["TransferAssetParameters", "TransferParameters", "transfer", "transfer_asset"] logger = logging.getLogger(__name__) diff --git a/src/algokit_utils/_legacy_v2/account.py b/src/algokit_utils/_legacy_v2/account.py index d98a875a..9da21ca1 100644 --- a/src/algokit_utils/_legacy_v2/account.py +++ b/src/algokit_utils/_legacy_v2/account.py @@ -5,6 +5,7 @@ from algosdk.account import address_from_private_key from algosdk.mnemonic import from_private_key, to_private_key from algosdk.util import algos_to_microalgos +from typing_extensions import deprecated from algokit_utils._legacy_v2._transfer import TransferParameters, transfer from algokit_utils._legacy_v2.network_clients import get_kmd_client_from_algod_client, is_localnet @@ -30,13 +31,17 @@ _DEFAULT_ACCOUNT_MINIMUM_BALANCE = 1_000_000_000 +@deprecated( + "Use `algorand.account.from_mnemonic()` instead. Example: " "`account = algorand.account.from_mnemonic(mnemonic)`" +) def get_account_from_mnemonic(mnemonic: str) -> Account: """Convert a mnemonic (25 word passphrase) into an Account""" private_key = to_private_key(mnemonic) - address = address_from_private_key(private_key) + address = str(address_from_private_key(private_key)) return Account(private_key=private_key, address=address) +@deprecated("Use `algorand.account.from_kmd()` instead. Example: " "`account = algorand.account.from_kmd(name)`") def create_kmd_wallet_account(kmd_client: "KMDClient", name: str) -> Account: """Creates a wallet with specified name""" wallet_id = kmd_client.create_wallet(name, "")["id"] @@ -50,6 +55,10 @@ def create_kmd_wallet_account(kmd_client: "KMDClient", name: str) -> Account: return get_account_from_mnemonic(from_private_key(private_account_key)) +@deprecated( + "Use `algorand.account.from_kmd()` instead. Example: " + "`account = algorand.account.from_kmd(name, fund_with=AlgoAmount.from_algo(1000))`" +) def get_or_create_kmd_wallet_account( client: "AlgodClient", name: str, fund_with_algos: float = 1000, kmd_client: "KMDClient | None" = None ) -> Account: @@ -90,6 +99,10 @@ def _is_default_account(account: dict[str, Any]) -> bool: return bool(account["status"] != "Offline" and account["amount"] > _DEFAULT_ACCOUNT_MINIMUM_BALANCE) +@deprecated( + "Use `algorand.account.from_kmd()` instead. Example: " + "`account = algorand.account.from_kmd('unencrypted-default-wallet', lambda a: a['status'] != 'Offline' and a['amount'] > 1_000_000_000)`" +) def get_localnet_default_account(client: "AlgodClient") -> Account: """Returns the default Account in a LocalNet instance""" if not is_localnet(client): @@ -102,6 +115,10 @@ def get_localnet_default_account(client: "AlgodClient") -> Account: return account +@deprecated( + "Use `algorand.account.dispenser_from_environment()` or `algorand.account.localnet_dispenser()` instead. " + "Example: `dispenser = algorand.account.dispenser_from_environment()`" +) def get_dispenser_account(client: "AlgodClient") -> Account: """Returns an Account based on DISPENSER_MNENOMIC environment variable or the default account on LocalNet""" if is_localnet(client): @@ -109,6 +126,9 @@ def get_dispenser_account(client: "AlgodClient") -> Account: return get_account(client, "DISPENSER") +@deprecated( + "Use `algorand.account.from_kmd()` instead. Example: " "`account = algorand.account.from_kmd(name, predicate)`" +) def get_kmd_wallet_account( client: "AlgodClient", kmd_client: "KMDClient", @@ -142,6 +162,11 @@ def get_kmd_wallet_account( return get_account_from_mnemonic(from_private_key(private_account_key)) +@deprecated( + "Use `algorand.account.from_environment()` or `algorand.account.from_kmd()` or `algorand.account.random()` instead. " + "Example: " + "`account = algorand.account.from_environment('ACCOUNT', AlgoAmount.from_algo(1000))`" +) def get_account( client: "AlgodClient", name: str, fund_with_algos: float = 1000, kmd_client: "KMDClient | None" = None ) -> Account: diff --git a/src/algokit_utils/_legacy_v2/application_client.py b/src/algokit_utils/_legacy_v2/application_client.py index a52639d1..0334c832 100644 --- a/src/algokit_utils/_legacy_v2/application_client.py +++ b/src/algokit_utils/_legacy_v2/application_client.py @@ -27,6 +27,7 @@ from algosdk.constants import APP_PAGE_MAX_SIZE from algosdk.logic import get_application_address from algosdk.source_map import SourceMap +from typing_extensions import deprecated import algokit_utils._legacy_v2.application_specification as au_spec import algokit_utils._legacy_v2.deploy as au_deploy @@ -83,6 +84,16 @@ def num_extra_program_pages(approval: bytes, clear: bytes) -> int: return ceil(((len(approval) + len(clear)) - APP_PAGE_MAX_SIZE) / APP_PAGE_MAX_SIZE) +@deprecated( + "Use AppClient from algokit_utils.applications instead. Example:\n" + "```python\n" + "from algokit_utils.clients import AlgorandClient\n" + "from algokit_utils.models.application import Arc56Contract\n" + "algorand_client = AlgorandClient.from_environment()\n" + "app_client = AppClient.from_network(app_spec=Arc56Contract.from_json(app_spec_json), " + "algorand=algorand_client, app_id=123)\n" + "```" +) class ApplicationClient: """A class that wraps an ARC-0032 app spec and provides high productivity methods to deploy and call the app""" @@ -239,7 +250,7 @@ def prepare( ) return new_client - def _prepare( # noqa: PLR0913 + def _prepare( self, target: "ApplicationClient", *, @@ -913,9 +924,9 @@ def _check_app_id(self) -> None: if self.app_id == 0: raise Exception( "ApplicationClient is not associated with an app instance, to resolve either:\n" - "1.) provide an app_id on construction OR\n" - "2.) provide a creator address so an app can be searched for OR\n" - "3.) create an app first using create or deploy methods" + "1.provide an app_id on construction OR\n" + "2.provide a creator address so an app can be searched for OR\n" + "3.create an app first using create or deploy methods" ) def _resolve_method( @@ -1254,6 +1265,10 @@ def _try_convert_to_logic_error( return None +@deprecated( + "The execute_atc_with_logic_error function is deprecated; use AppClient's error handling and TransactionComposer's " + "send method for equivalent functionality and improved error management." +) def execute_atc_with_logic_error( atc: AtomicTransactionComposer, algod_client: "AlgodClient", diff --git a/src/algokit_utils/_legacy_v2/application_specification.py b/src/algokit_utils/_legacy_v2/application_specification.py index 865dece5..5b034929 100644 --- a/src/algokit_utils/_legacy_v2/application_specification.py +++ b/src/algokit_utils/_legacy_v2/application_specification.py @@ -8,16 +8,17 @@ from algosdk.abi import Contract from algosdk.abi.method import MethodDict from algosdk.transaction import StateSchema +from typing_extensions import deprecated __all__ = [ + "AppSpecStateDict", + "ApplicationSpecification", "CallConfig", "DefaultArgumentDict", "DefaultArgumentType", "MethodConfigDict", - "OnCompleteActionName", "MethodHints", - "ApplicationSpecification", - "AppSpecStateDict", + "OnCompleteActionName", ] @@ -136,6 +137,10 @@ def _decode_state_schema(data: dict[str, int]) -> StateSchema: ) +@deprecated( + "The ApplicationSpecification class is deprecated. Use Arc56Contract and the TransactionComposer and AppClient " + "classes for modern application development." +) @dataclasses.dataclass(kw_only=True) class ApplicationSpecification: """ARC-0032 application specification diff --git a/src/algokit_utils/_legacy_v2/asset.py b/src/algokit_utils/_legacy_v2/asset.py index 2f71cbf8..409523c9 100644 --- a/src/algokit_utils/_legacy_v2/asset.py +++ b/src/algokit_utils/_legacy_v2/asset.py @@ -4,6 +4,7 @@ from algosdk.atomic_transaction_composer import AtomicTransactionComposer, TransactionWithSigner from algosdk.constants import TX_GROUP_LIMIT from algosdk.transaction import AssetTransferTxn +from typing_extensions import deprecated if TYPE_CHECKING: from algosdk.v2client.algod import AlgodClient @@ -68,6 +69,10 @@ def _ensure_asset_balance_conditions( raise ValueError(error_message) +@deprecated( + "Use TransactionComposer.add_asset_opt_in() or AlgorandClient.asset.opt_in() instead. " + "Example: composer.add_asset_opt_in(AssetOptInParams(sender=account.address, asset_id=123))" +) def opt_in(algod_client: "AlgodClient", account: Account, asset_ids: list[int]) -> dict[int, str]: """ Opt-in to a list of assets on the Algorand blockchain. Before an account can receive a specific asset, @@ -116,6 +121,10 @@ def opt_in(algod_client: "AlgodClient", account: Account, asset_ids: list[int]) return result +@deprecated( + "Use TransactionComposer.add_asset_opt_out() or AlgorandClient.asset.opt_out() instead. " + "Example: composer.add_asset_opt_out(AssetOptOutParams(sender=account.address, asset_id=123, creator=creator_address))" +) def opt_out(algod_client: "AlgodClient", account: Account, asset_ids: list[int]) -> dict[int, str]: """ Opt out from a list of Algorand Standard Assets (ASAs) by transferring them back to their creators. diff --git a/src/algokit_utils/_legacy_v2/deploy.py b/src/algokit_utils/_legacy_v2/deploy.py index ed0bd0e5..6a73ba45 100644 --- a/src/algokit_utils/_legacy_v2/deploy.py +++ b/src/algokit_utils/_legacy_v2/deploy.py @@ -7,11 +7,11 @@ from enum import Enum from typing import TYPE_CHECKING, TypeAlias, TypedDict +import algosdk from algosdk import transaction from algosdk.atomic_transaction_composer import AtomicTransactionComposer, TransactionSigner -from algosdk.logic import get_application_address from algosdk.transaction import StateSchema -from deprecated import deprecated +from typing_extensions import deprecated from algokit_utils._legacy_v2.application_specification import ( ApplicationSpecification, @@ -36,26 +36,26 @@ __all__ = [ - "UPDATABLE_TEMPLATE_NAME", "DELETABLE_TEMPLATE_NAME", "NOTE_PREFIX", + "UPDATABLE_TEMPLATE_NAME", "ABICallArgs", - "ABICreateCallArgs", "ABICallArgsDict", + "ABICreateCallArgs", "ABICreateCallArgsDict", - "DeploymentFailedError", - "AppReference", "AppDeployMetaData", - "AppMetaData", "AppLookup", + "AppMetaData", + "AppReference", "DeployCallArgs", - "DeployCreateCallArgs", "DeployCallArgsDict", + "DeployCreateCallArgs", "DeployCreateCallArgsDict", - "Deployer", "DeployResponse", - "OnUpdate", + "Deployer", + "DeploymentFailedError", "OnSchemaBreak", + "OnUpdate", "OperationPerformed", "TemplateValueDict", "TemplateValueMapping", @@ -175,6 +175,7 @@ def _parse_note(metadata_b64: str | None) -> AppDeployMetaData | None: return None +@deprecated("Deprecated") def get_creator_apps(indexer: "IndexerClient", creator_account: Account | str) -> AppLookup: """Returns a mapping of Application names to {py:class}`AppMetaData` for all Applications created by specified creator that have a transaction note containing {py:class}`AppDeployMetaData` @@ -222,7 +223,7 @@ def get_creator_apps(indexer: "IndexerClient", creator_account: Account | str) - if create_metadata and create_metadata.name: apps[create_metadata.name] = AppMetaData( app_id=app_id, - app_address=get_application_address(app_id), + app_address=algosdk.logic.get_application_address(app_id), created_metadata=create_metadata, created_round=app_created_at_round, **(update_metadata or create_metadata).__dict__, @@ -255,7 +256,8 @@ class AppChanges: schema_change_description: str | None -def check_for_app_changes( # noqa: PLR0913 +@deprecated("Deprecated") +def check_for_app_changes( algod_client: "AlgodClient", *, new_approval: bytes, @@ -412,7 +414,7 @@ def check_template_variables(approval_program: str, template_values: TemplateVal logger.warning(f"{tmpl_variable} not found in approval program, but variable was provided") -@deprecated(reason="Use `AppManager.replace_template_variables` instead", version="3.0.0") +@deprecated("Use `AppManager.replace_template_variables` instead") def replace_template_variables(program: str, template_values: TemplateValueMapping) -> str: """Replaces `TMPL_*` variables in `program` with `template_values` @@ -809,7 +811,7 @@ def _create_metadata( ) -> AppMetaData: return AppMetaData( app_id=app_id, - app_address=get_application_address(app_id), + app_address=algosdk.logic.get_application_address(app_id), created_metadata=original_metadata or app_spec_note, created_round=created_round, updated_round=updated_round or created_round, diff --git a/src/algokit_utils/_legacy_v2/logic_error.py b/src/algokit_utils/_legacy_v2/logic_error.py index a365a3c1..a556d90f 100644 --- a/src/algokit_utils/_legacy_v2/logic_error.py +++ b/src/algokit_utils/_legacy_v2/logic_error.py @@ -2,6 +2,8 @@ from copy import copy from typing import TYPE_CHECKING, TypedDict +from typing_extensions import deprecated + from algokit_utils._legacy_v2.models import SimulationTrace if TYPE_CHECKING: @@ -37,8 +39,9 @@ def parse_logic_error( } +@deprecated("Use algokit_utils.models.error.LogicError instead") class LogicError(Exception): - def __init__( # noqa: PLR0913 + def __init__( self, *, logic_error_str: str, @@ -74,9 +77,9 @@ def trace(self, lines: int = 5) -> str: return """ Could not determine TEAL source line for the error as no approval source map was provided, to receive a trace of the error please provide an approval SourceMap. Either by: - 1.) Providing template_values when creating the ApplicationClient, so a SourceMap can be obtained automatically OR - 2.) Set approval_source_map from a previously compiled approval program OR - 3.) Import a previously exported source map using import_source_map""" + 1. Providing template_values when creating the ApplicationClient, so a SourceMap can be obtained automatically OR + 2. Set approval_source_map from a previously compiled approval program OR + 3. Import a previously exported source map using import_source_map""" program_lines = copy(self.lines) program_lines[self.line_no] += "\t\t<-- Error" diff --git a/src/algokit_utils/_legacy_v2/models.py b/src/algokit_utils/_legacy_v2/models.py index d20bed83..7887cb60 100644 --- a/src/algokit_utils/_legacy_v2/models.py +++ b/src/algokit_utils/_legacy_v2/models.py @@ -9,7 +9,7 @@ SimulateAtomicTransactionResponse, TransactionSigner, ) -from deprecated import deprecated +from typing_extensions import deprecated # Imports from latest sdk version that rely on models previously used in legacy v2 (but moved to root models/*) @@ -185,17 +185,17 @@ class CreateCallParametersDict(OnCompleteCallParametersDict, total=False): # Pre 1.3.1 backwards compatibility -@deprecated(reason="Use TransactionParameters instead", version="1.3.1") +@deprecated("Use TransactionParameters instead") class RawTransactionParameters(TransactionParameters): """Deprecated, use TransactionParameters instead""" -@deprecated(reason="Use TransactionParameters instead", version="1.3.1") +@deprecated("Use TransactionParameters instead") class CommonCallParameters(TransactionParameters): """Deprecated, use TransactionParameters instead""" -@deprecated(reason="Use TransactionParametersDict instead", version="1.3.1") +@deprecated("Use TransactionParametersDict instead") class CommonCallParametersDict(TransactionParametersDict): """Deprecated, use TransactionParametersDict instead""" diff --git a/src/algokit_utils/_legacy_v2/network_clients.py b/src/algokit_utils/_legacy_v2/network_clients.py index b1bcc2cb..4d1341b9 100644 --- a/src/algokit_utils/_legacy_v2/network_clients.py +++ b/src/algokit_utils/_legacy_v2/network_clients.py @@ -6,19 +6,20 @@ from algosdk.kmd import KMDClient from algosdk.v2client.algod import AlgodClient from algosdk.v2client.indexer import IndexerClient +from typing_extensions import deprecated __all__ = [ "AlgoClientConfig", + "AlgoClientConfigs", "get_algod_client", "get_algonode_config", "get_default_localnet_config", "get_indexer_client", + "get_kmd_client", "get_kmd_client_from_algod_client", "is_localnet", "is_mainnet", "is_testnet", - "AlgoClientConfigs", - "get_kmd_client", ] @@ -40,12 +41,14 @@ class AlgoClientConfigs: kmd_config: AlgoClientConfig | None +@deprecated("Use AlgorandClient.client.algod") def get_default_localnet_config(config: Literal["algod", "indexer", "kmd"]) -> AlgoClientConfig: """Returns the client configuration to point to the default LocalNet""" port = {"algod": 4001, "indexer": 8980, "kmd": 4002}[config] return AlgoClientConfig(server=f"http://localhost:{port}", token="a" * 64) +@deprecated("Use AlgorandClient.client.test_net() or AlgorandClient.main_net() instead") def get_algonode_config( network: Literal["testnet", "mainnet"], config: Literal["algod", "indexer"], token: str ) -> AlgoClientConfig: @@ -56,6 +59,7 @@ def get_algonode_config( ) +@deprecated("Use AlgorandClient.client.from_environment() instead. Example: client = AlgorandClient.from_environment()") def get_algod_client(config: AlgoClientConfig | None = None) -> AlgodClient: """Returns an {py:class}`algosdk.v2client.algod.AlgodClient` from `config` or environment @@ -65,6 +69,7 @@ def get_algod_client(config: AlgoClientConfig | None = None) -> AlgodClient: return AlgodClient(config.token, config.server, headers) +@deprecated("Use AlgorandClient.client.default_local_net().kmd instead") def get_kmd_client(config: AlgoClientConfig | None = None) -> KMDClient: """Returns an {py:class}`algosdk.kmd.KMDClient` from `config` or environment @@ -73,6 +78,7 @@ def get_kmd_client(config: AlgoClientConfig | None = None) -> KMDClient: return KMDClient(config.token, config.server) +@deprecated("Use AlgorandClient.client.from_environment().indexer instead") def get_indexer_client(config: AlgoClientConfig | None = None) -> IndexerClient: """Returns an {py:class}`algosdk.v2client.indexer.IndexerClient` from `config` or environment. @@ -82,24 +88,28 @@ def get_indexer_client(config: AlgoClientConfig | None = None) -> IndexerClient: return IndexerClient(config.token, config.server, headers) +@deprecated("Use AlgorandClient.client.is_local_net() instead") def is_localnet(client: AlgodClient) -> bool: """Returns True if client genesis is `devnet-v1` or `sandnet-v1`""" params = client.suggested_params() return params.gen in ["devnet-v1", "sandnet-v1", "dockernet-v1"] +@deprecated("Use AlgorandClient.client.is_main_net() instead") def is_mainnet(client: AlgodClient) -> bool: """Returns True if client genesis is `mainnet-v1`""" params = client.suggested_params() return params.gen in ["mainnet-v1.0", "mainnet-v1", "mainnet"] +@deprecated("Use AlgorandClient.client.is_test_net() instead") def is_testnet(client: AlgodClient) -> bool: """Returns True if client genesis is `testnet-v1`""" params = client.suggested_params() return params.gen in ["testnet-v1.0", "testnet-v1", "testnet"] +@deprecated("Use AlgorandClient.client.default_local_net().kmd instead") def get_kmd_client_from_algod_client(client: AlgodClient) -> KMDClient: """Returns an {py:class}`algosdk.kmd.KMDClient` from supplied `client` diff --git a/src/algokit_utils/accounts/account_manager.py b/src/algokit_utils/accounts/account_manager.py index d4d95d19..d997a211 100644 --- a/src/algokit_utils/accounts/account_manager.py +++ b/src/algokit_utils/accounts/account_manager.py @@ -1,19 +1,44 @@ +import os from collections.abc import Callable from dataclasses import dataclass from typing import Any -from algosdk.account import generate_account +from algosdk import mnemonic from algosdk.atomic_transaction_composer import AccountTransactionSigner, TransactionSigner +from algosdk.mnemonic import to_private_key +from algosdk.transaction import SuggestedParams from typing_extensions import Self -from algokit_utils.account import get_dispenser_account, get_kmd_wallet_account, get_localnet_default_account +from algokit_utils.accounts.kmd_account_manager import KmdAccountManager from algokit_utils.clients.client_manager import ClientManager +from algokit_utils.clients.dispenser_api_client import DispenserAssetName, TestNetDispenserApiClient +from algokit_utils.config import config +from algokit_utils.models.account import DISPENSER_ACCOUNT_NAME, Account +from algokit_utils.models.amount import AlgoAmount +from algokit_utils.transactions.transaction_composer import ( + PaymentParams, + SendAtomicTransactionComposerResults, + TransactionComposer, +) +from algokit_utils.transactions.transaction_sender import SendSingleTransactionResult +logger = config.logger -@dataclass -class AddressAndSigner: - address: str - signer: TransactionSigner + +@dataclass(frozen=True, kw_only=True) +class _CommonEnsureFundedParams: + transaction_id: str + amount_funded: AlgoAmount + + +@dataclass(frozen=True, kw_only=True) +class EnsureFundedResponse(SendSingleTransactionResult, _CommonEnsureFundedParams): + pass + + +@dataclass(frozen=True, kw_only=True) +class EnsureFundedFromTestnetDispenserApiResponse(_CommonEnsureFundedParams): + pass class AccountManager: @@ -26,14 +51,15 @@ def __init__(self, client_manager: ClientManager): :param client_manager: The ClientManager client to use for algod and kmd clients """ self._client_manager = client_manager - self._accounts = dict[str, TransactionSigner]() + self._kmd_account_manager = KmdAccountManager(client_manager) + self._accounts = dict[str, Account]() self._default_signer: TransactionSigner | None = None def set_default_signer(self, signer: TransactionSigner) -> Self: """ Sets the default signer to use if no other signer is specified. - :param signer: The signer to use, either a `TransactionSigner` or a `TransactionSignerAccount` + :param signer: The signer to use :return: The `AccountManager` so method calls can be chained """ self._default_signer = signer @@ -47,10 +73,17 @@ def set_signer(self, sender: str, signer: TransactionSigner) -> Self: :param signer: The signer to sign transactions with for the given sender :return: The AccountCreator instance for method chaining """ - self._accounts[sender] = signer + if isinstance(signer, AccountTransactionSigner): + self._accounts[sender] = Account(private_key=signer.private_key) return self - def get_signer(self, sender: str) -> TransactionSigner: + def get_account(self, sender: str) -> Account: + account = self._accounts.get(sender) + if not account: + raise ValueError(f"No account found for address {sender}") + return account + + def get_signer(self, sender: str | Account) -> TransactionSigner: """ Returns the `TransactionSigner` for the given sender address. @@ -59,82 +92,400 @@ def get_signer(self, sender: str) -> TransactionSigner: :param sender: The sender address :return: The `TransactionSigner` or throws an error if not found """ - signer = self._accounts.get(sender, None) or self._default_signer + account = self._accounts.get(self._get_address(sender)) + signer = account.signer if account else self._default_signer if not signer: raise ValueError(f"No signer found for address {sender}") return signer - def get_information(self, sender: str) -> dict[str, Any]: + def get_information(self, sender: str | Account) -> dict[str, Any]: """ Returns the given sender account's current status, balance and spendable amounts. - Example: - address = "XBYLS2E6YI6XXL5BWCAMOA4GTWHXWENZMX5UHXMRNWWUQ7BXCY5WC5TEPA" - account_info = account.get_information(address) - - `Response data schema details `_ - :param sender: The address of the sender/account to look up :return: The account information """ - info = self._client_manager.algod.account_info(sender) + info = self._client_manager.algod.account_info(self._get_address(sender)) assert isinstance(info, dict) return info - def get_asset_information(self, sender: str, asset_id: int) -> dict[str, Any]: - info = self._client_manager.algod.account_asset_info(sender, asset_id) - assert isinstance(info, dict) - return info + def from_mnemonic(self, mnemonic: str) -> Account: + private_key = to_private_key(mnemonic) + account = Account(private_key=private_key) + self._accounts[account.address] = account + self.set_signer(account.address, AccountTransactionSigner(private_key=private_key)) + return account + + def from_environment(self, name: str, fund_with: AlgoAmount | None = None) -> Account: + account_mnemonic = os.getenv(f"{name.upper()}_MNEMONIC") + + if account_mnemonic: + private_key = mnemonic.to_private_key(account_mnemonic) + account = Account(private_key=private_key) + self._accounts[account.address] = account + self.set_signer(account.address, AccountTransactionSigner(private_key=private_key)) + return account + + if self._client_manager.is_local_net(): + kmd_account = self._kmd_account_manager.get_or_create_wallet_account(name, fund_with) + account = Account(private_key=kmd_account.private_key) + self._accounts[account.address] = account + self.set_signer(account.address, AccountTransactionSigner(private_key=kmd_account.private_key)) + return account + + raise ValueError(f"Missing environment variable {name.upper()}_MNEMONIC when looking for account {name}") def from_kmd( - self, - name: str, - predicate: Callable[[dict[str, Any]], bool] | None = None, - ) -> AddressAndSigner: - account = get_kmd_wallet_account( - name=name, predicate=predicate, client=self._client_manager.algod, kmd_client=self._client_manager.kmd - ) - if not account: + self, name: str, predicate: Callable[[dict[str, Any]], bool] | None = None, sender: str | None = None + ) -> Account: + kmd_account = self._kmd_account_manager.get_wallet_account(name, predicate, sender) + if not kmd_account: raise ValueError(f"Unable to find KMD account {name}{' with predicate' if predicate else ''}") - self.set_signer(account.address, account.signer) - return AddressAndSigner(address=account.address, signer=account.signer) + account = Account(private_key=kmd_account.private_key) + self._accounts[account.address] = account + self.set_signer(account.address, AccountTransactionSigner(private_key=kmd_account.private_key)) + return account - def random(self) -> AddressAndSigner: + def rekeyed(self, sender: Account | str, account: Account) -> Account: + sender_address = sender.address if isinstance(sender, Account) else sender + self._accounts[sender_address] = account + return Account(address=sender_address, private_key=account.private_key) + + def rekey_account( # noqa: PLR0913 + self, + account: str | Account, + rekey_to: str | Account, + *, + # Common transaction parameters + signer: TransactionSigner | None = None, + note: bytes | None = None, + lease: bytes | None = None, + static_fee: AlgoAmount | None = None, + extra_fee: AlgoAmount | None = None, + max_fee: AlgoAmount | None = None, + validity_window: int | None = None, + first_valid_round: int | None = None, + last_valid_round: int | None = None, + suppress_log: bool | None = None, + ) -> SendAtomicTransactionComposerResults: + """Rekey an account to a new address. + + Args: + account: The account to rekey + rekey_to: The address or account to rekey to + signer: Optional transaction signer + note: Optional transaction note + lease: Optional transaction lease + static_fee: Optional static fee + extra_fee: Optional extra fee + max_fee: Optional max fee + validity_window: Optional validity window + first_valid_round: Optional first valid round + last_valid_round: Optional last valid round + suppress_log: Optional flag to suppress logging + + Returns: + The transaction result """ - Tracks and returns a new, random Algorand account with secret key loaded. + sender_address = self._get_address(account) + rekey_address = self._get_address(rekey_to) + + result = ( + self._get_composer() + .add_payment( + PaymentParams( + sender=sender_address, + receiver=sender_address, + amount=AlgoAmount.from_micro_algo(0), + rekey_to=rekey_address, + signer=signer, + note=note, + lease=lease, + static_fee=static_fee, + extra_fee=extra_fee, + max_fee=max_fee, + validity_window=validity_window, + first_valid_round=first_valid_round, + last_valid_round=last_valid_round, + suppress_log=suppress_log, + ) + ) + .send() + ) + + # If rekey_to is a signing account, set it as the signer for this account + if isinstance(rekey_to, Account): + self.rekeyed(account, rekey_to) + + if not suppress_log: + logger.info(f"Rekeyed {account} to {rekey_to} via transaction {result.tx_ids[-1]}") + + return result - Example: - account = account.random() + def random(self) -> Account: + """ + Tracks and returns a new, random Algorand account. :return: The account """ - (sk, addr) = generate_account() - signer = AccountTransactionSigner(sk) + account = Account.new_account() + self._accounts[account.address] = account + self.set_signer(account.address, AccountTransactionSigner(private_key=account.private_key)) + return account + + def localnet_dispenser(self) -> Account: + kmd_account = self._kmd_account_manager.get_localnet_dispenser_account() + account = Account(private_key=kmd_account.private_key) + self._accounts[account.address] = account + self.set_signer(account.address, AccountTransactionSigner(private_key=kmd_account.private_key)) + return account + + def dispenser_from_environment(self) -> Account: + name = os.getenv(f"{DISPENSER_ACCOUNT_NAME}_MNEMONIC") + if name: + return self.from_environment(DISPENSER_ACCOUNT_NAME) + return self.localnet_dispenser() + + def ensure_funded( # noqa: PLR0913 + self, + account_to_fund: str | Account, + dispenser_account: str | Account, + min_spending_balance: AlgoAmount, + min_funding_increment: AlgoAmount | None = None, + # Sender params + max_rounds_to_wait: int | None = None, + suppress_log: bool | None = None, + populate_app_call_resources: bool | None = None, + # Common txn params + signer: TransactionSigner | None = None, + rekey_to: str | None = None, + note: bytes | None = None, + lease: bytes | None = None, + static_fee: AlgoAmount | None = None, + extra_fee: AlgoAmount | None = None, + max_fee: AlgoAmount | None = None, + validity_window: int | None = None, + first_valid_round: int | None = None, + last_valid_round: int | None = None, + ) -> EnsureFundedResponse | None: + account_to_fund = self._get_address(account_to_fund) + dispenser_account = self._get_address(dispenser_account) + amount_funded = self._get_ensure_funded_amount(account_to_fund, min_spending_balance, min_funding_increment) + + if not amount_funded: + return None + + result = ( + self._get_composer() + .add_payment( + PaymentParams( + sender=dispenser_account, + receiver=account_to_fund, + amount=amount_funded, + signer=signer, + rekey_to=rekey_to, + note=note, + lease=lease, + static_fee=static_fee, + extra_fee=extra_fee, + max_fee=max_fee, + validity_window=validity_window, + first_valid_round=first_valid_round, + last_valid_round=last_valid_round, + ) + ) + .send( + max_rounds_to_wait=max_rounds_to_wait, + suppress_log=suppress_log, + populate_app_call_resources=populate_app_call_resources, + ) + ) - self.set_signer(addr, signer) + return EnsureFundedResponse( + returns=result.returns, + transactions=result.transactions, + confirmations=result.confirmations, + tx_ids=result.tx_ids, + group_id=result.group_id, + transaction_id=result.tx_ids[0], + confirmation=result.confirmations[0], + transaction=result.transactions[0], + amount_funded=amount_funded, + ) + + def ensure_funded_from_environment( # noqa: PLR0913 + self, + account_to_fund: str | Account, + min_spending_balance: AlgoAmount, + *, # Force remaining params to be keyword-only + min_funding_increment: AlgoAmount | None = None, + # SendParams + max_rounds_to_wait: int | None = None, + suppress_log: bool | None = None, + populate_app_call_resources: bool | None = None, + # Common transaction params (omitting sender) + signer: TransactionSigner | None = None, + rekey_to: str | None = None, + note: bytes | None = None, + lease: bytes | None = None, + static_fee: AlgoAmount | None = None, + extra_fee: AlgoAmount | None = None, + max_fee: AlgoAmount | None = None, + validity_window: int | None = None, + first_valid_round: int | None = None, + last_valid_round: int | None = None, + ) -> EnsureFundedResponse | None: + """Ensure an account is funded from a dispenser account configured in environment. + + Args: + account_to_fund: Address of account to fund + min_spending_balance: Minimum spending balance to ensure + min_funding_increment: Optional minimum funding increment + max_rounds_to_wait: Optional maximum rounds to wait for transaction + suppress_log: Optional flag to suppress logging + populate_app_call_resources: Optional flag to populate app call resources + signer: Optional transaction signer + rekey_to: Optional rekey address + note: Optional transaction note + lease: Optional transaction lease + static_fee: Optional static fee + extra_fee: Optional extra fee + max_fee: Optional maximum fee + validity_window: Optional validity window + first_valid_round: Optional first valid round + last_valid_round: Optional last valid round + + Returns: + EnsureFundedResponse if funding was needed, None otherwise + """ + account_to_fund = self._get_address(account_to_fund) + dispenser_account = self.dispenser_from_environment() + + amount_funded = self._get_ensure_funded_amount(account_to_fund, min_spending_balance, min_funding_increment) + + if not amount_funded: + return None + + result = ( + self._get_composer() + .add_payment( + PaymentParams( + sender=dispenser_account.address, + receiver=account_to_fund, + amount=amount_funded, + signer=signer, + rekey_to=rekey_to, + note=note, + lease=lease, + static_fee=static_fee, + extra_fee=extra_fee, + max_fee=max_fee, + validity_window=validity_window, + first_valid_round=first_valid_round, + last_valid_round=last_valid_round, + ) + ) + .send( + max_rounds_to_wait=max_rounds_to_wait, + suppress_log=suppress_log, + populate_app_call_resources=populate_app_call_resources, + ) + ) - return AddressAndSigner(address=addr, signer=signer) + return EnsureFundedResponse( + returns=result.returns, + transactions=result.transactions, + confirmations=result.confirmations, + tx_ids=result.tx_ids, + group_id=result.group_id, + transaction_id=result.tx_ids[0], + confirmation=result.confirmations[0], + transaction=result.transactions[0], + amount_funded=amount_funded, + ) - def dispenser(self) -> AddressAndSigner: + def ensure_funded_from_testnet_dispenser_api( + self, + account_to_fund: str | Account, + dispenser_client: TestNetDispenserApiClient, + min_spending_balance: AlgoAmount, + *, # Force remaining params to be keyword-only + min_funding_increment: AlgoAmount | None = None, + ) -> EnsureFundedFromTestnetDispenserApiResponse | None: + """Ensure an account is funded using the TestNet Dispenser API. + + Args: + account_to_fund: Address of account to fund + dispenser_client: Instance of TestNetDispenserApiClient to use for funding + min_spending_balance: Minimum spending balance to ensure + min_funding_increment: Optional minimum funding increment + + Returns: + EnsureFundedResponse if funding was needed, None otherwise + + Raises: + ValueError: If attempting to fund on non-TestNet network """ - Returns an account (with private key loaded) that can act as a dispenser. + account_to_fund = self._get_address(account_to_fund) - Example: - account = account.dispenser() + if not self._client_manager.is_test_net(): + raise ValueError("Attempt to fund using TestNet dispenser API on non TestNet network.") - If running on LocalNet then it will return the default dispenser account automatically, - otherwise it will load the account mnemonic stored in os.environ['DISPENSER_MNEMONIC']. + amount_funded = self._get_ensure_funded_amount(account_to_fund, min_spending_balance, min_funding_increment) - :return: The account - """ - acct = get_dispenser_account(self._client_manager.algod) + if not amount_funded: + return None - self.set_signer(acct.address, acct.signer) + result = dispenser_client.fund( + address=account_to_fund, + amount=amount_funded.micro_algo, + asset_id=DispenserAssetName.ALGO, + ) + + return EnsureFundedFromTestnetDispenserApiResponse( + transaction_id=result.tx_id, + amount_funded=AlgoAmount.from_micro_algo(result.amount), + ) + + def _get_address(self, sender: str | Account) -> str: + return sender.address if isinstance(sender, Account) else sender + + def _get_composer(self, get_suggested_params: Callable[[], SuggestedParams] | None = None) -> TransactionComposer: + if get_suggested_params is None: + + def _get_suggested_params() -> SuggestedParams: + return self._client_manager.algod.suggested_params() - return AddressAndSigner(address=acct.address, signer=acct.signer) + get_suggested_params = _get_suggested_params + + return TransactionComposer( + algod=self._client_manager.algod, get_signer=self.get_signer, get_suggested_params=get_suggested_params + ) + + def _calculate_fund_amount( + self, + min_spending_balance: int, + current_spending_balance: int, + min_funding_increment: int, + ) -> int | None: + if min_spending_balance > current_spending_balance: + min_fund_amount = min_spending_balance - current_spending_balance + return max(min_fund_amount, min_funding_increment) + return None + + def _get_ensure_funded_amount( + self, + sender: str, + min_spending_balance: AlgoAmount, + min_funding_increment: AlgoAmount | None = None, + ) -> AlgoAmount | None: + account_info = self.get_information(sender) + current_spending_balance = account_info["amount"] - account_info["min-balance"] + + min_increment = min_funding_increment.micro_algo if min_funding_increment else 0 + amount_funded = self._calculate_fund_amount( + min_spending_balance.micro_algo, current_spending_balance, min_increment + ) - def localnet_dispenser(self) -> AddressAndSigner: - acct = get_localnet_default_account(self._client_manager.algod) - self.set_signer(acct.address, acct.signer) - return AddressAndSigner(address=acct.address, signer=acct.signer) + return AlgoAmount.from_micro_algo(amount_funded) if amount_funded is not None else None diff --git a/src/algokit_utils/accounts/kmd_account_manager.py b/src/algokit_utils/accounts/kmd_account_manager.py new file mode 100644 index 00000000..6ac08c2d --- /dev/null +++ b/src/algokit_utils/accounts/kmd_account_manager.py @@ -0,0 +1,190 @@ +from collections.abc import Callable +from typing import Any, cast + +from algosdk.kmd import KMDClient + +from algokit_utils.clients.client_manager import ClientManager +from algokit_utils.config import config +from algokit_utils.models.account import Account +from algokit_utils.models.amount import AlgoAmount +from algokit_utils.transactions.transaction_composer import PaymentParams, TransactionComposer + +logger = config.logger + + +class KmdAccount(Account): + """Account retrieved from KMD with signing capabilities, extending base Account""" + + def __init__(self, private_key: str, address: str | None = None) -> None: + """Initialize KMD account with private key and optional address override + + Args: + private_key: Base64 encoded private key + address: Optional address override (for rekeyed accounts) + """ + super().__init__(private_key=private_key, address=address or "") + + +class KmdAccountManager: + """Provides abstractions over KMD that makes it easier to get and manage accounts.""" + + _kmd: KMDClient | None + + def __init__(self, client_manager: ClientManager) -> None: + """Create a new KMD manager. + + Args: + client_manager: ClientManager to use for account management + """ + self._client_manager = client_manager + try: + self._kmd = client_manager.kmd + except ValueError: + self._kmd = None + + def kmd(self) -> KMDClient: + """Get the KMD client, initializing it if needed. + + Returns: + KMDClient: The initialized KMD client + + Raises: + Exception: If KMD is not configured + """ + if self._kmd is None: + if self._client_manager.is_local_net(): + kmd_config = ClientManager.get_config_from_environment_or_localnet() + self._kmd = ClientManager.get_kmd_client(kmd_config.kmd_config) + return self._kmd + raise Exception("Attempt to use KMD client with no KMD configured") + return self._kmd + + def get_wallet_account( + self, + wallet_name: str, + predicate: Callable[[dict[str, Any]], bool] | None = None, + sender: str | None = None, + ) -> KmdAccount | None: + """Returns an Algorand signing account with private key loaded from the given KMD wallet. + + Args: + wallet_name: The name of the wallet to retrieve an account from + predicate: Optional filter to use to find the account (otherwise returns a random account from the wallet) + sender: Optional sender address to use this signer for (aka a rekeyed account) + + Returns: + Optional[KmdAccount]: The signing account or None if no matching wallet or account was found + + Example: + ```python + # Get default funded account in a LocalNet + default_dispenser = kmd_manager.get_wallet_account( + "unencrypted-default-wallet", + lambda a: a["status"] != "Offline" and a["amount"] > 1_000_000_000 + ) + ``` + """ + kmd_client = self.kmd() + wallets = kmd_client.list_wallets() + wallet = next((w for w in wallets if w["name"] == wallet_name), None) + if not wallet: + return None + + wallet_id = wallet["id"] + wallet_handle = kmd_client.init_wallet_handle(wallet_id, "") + addresses = kmd_client.list_keys(wallet_handle) + + matched_address = None + if predicate: + for address in addresses: + account_info = self._client_manager.algod.account_info(address) + if predicate(cast(dict[str, Any], account_info)): + matched_address = address + break + else: + matched_address = next(iter(addresses), None) + + if not matched_address: + return None + + private_key = kmd_client.export_key(wallet_handle, "", matched_address) + return KmdAccount(private_key=private_key, address=sender) + + def get_or_create_wallet_account(self, name: str, fund_with: AlgoAmount | None = None) -> KmdAccount: + """Gets or creates a funded account in a KMD wallet of the given name. + + This is useful to get idempotent accounts from LocalNet without having to specify the private key + (which will change when resetting the LocalNet). + + Args: + name: The name of the wallet to retrieve / create + fund_with: The number of Algos to fund the account with when created (default: 1000) + + Returns: + KmdAccount: An Algorand account with private key loaded + + Example: + ```python + # Idempotently get (if exists) or create (if doesn't exist) an account by name using KMD + # if creating it then fund it with 2 ALGO from the default dispenser account + new_account = kmd_manager.get_or_create_wallet_account("account1", 2) + # This will return the same account as above since the name matches + existing_account = kmd_manager.get_or_create_wallet_account("account1") + ``` + """ + existing = self.get_wallet_account(name) + if existing: + return existing + + kmd_client = self.kmd() + wallet_id = kmd_client.create_wallet(name, "")["id"] + wallet_handle = kmd_client.init_wallet_handle(wallet_id, "") + kmd_client.generate_key(wallet_handle) + + account = self.get_wallet_account(name) + assert account is not None + + logger.info( + f"LocalNet account '{name}' doesn't yet exist; created account {account.address} " + f"with keys stored in KMD and funding with {fund_with} ALGO" + ) + + dispenser = self.get_localnet_dispenser_account() + TransactionComposer( + algod=self._client_manager.algod, + get_signer=lambda _: dispenser.signer, + get_suggested_params=self._client_manager.algod.suggested_params, + ).add_payment( + PaymentParams( + sender=dispenser.address, + receiver=account.address, + amount=fund_with or AlgoAmount.from_algo(1000), + ) + ).send() + return account + + def get_localnet_dispenser_account(self) -> KmdAccount: + """Returns an Algorand account with private key loaded for the default LocalNet dispenser account. + + Returns: + KmdAccount: The default LocalNet dispenser account + + Raises: + Exception: If not running against LocalNet or dispenser account not found + + Example: + ```python + dispenser = kmd_manager.get_localnet_dispenser_account() + ``` + """ + if not self._client_manager.is_local_net(): + raise Exception("Can't get LocalNet dispenser account from non LocalNet network") + + dispenser = self.get_wallet_account( + "unencrypted-default-wallet", + lambda a: a["status"] != "Offline" and a["amount"] > 1_000_000_000, # noqa: PLR2004 + ) + if not dispenser: + raise Exception("Error retrieving LocalNet dispenser account; couldn't find the default account in KMD") + + return dispenser diff --git a/src/algokit_utils/applications/app_client.py b/src/algokit_utils/applications/app_client.py new file mode 100644 index 00000000..4c71d171 --- /dev/null +++ b/src/algokit_utils/applications/app_client.py @@ -0,0 +1,1396 @@ +from __future__ import annotations + +import base64 +import copy +import json +import os +from dataclasses import dataclass, fields +from typing import TYPE_CHECKING, Any, Protocol, TypeVar + +import algosdk +from algosdk.source_map import SourceMap +from algosdk.transaction import OnComplete, Transaction + +from algokit_utils._legacy_v2.application_specification import ApplicationSpecification +from algokit_utils.applications.app_manager import BoxABIValue, BoxName, BoxValue +from algokit_utils.applications.utils import ( + get_abi_decoded_value, + get_abi_encoded_value, + get_abi_tuple_from_abi_struct, + get_arc56_method, +) +from algokit_utils.errors.logic_error import LogicError, parse_logic_error +from algokit_utils.models.application import ( + AppState, + Arc56Contract, + CompiledTeal, + ProgramSourceInfo, + SourceInfoDetail, + StorageKey, + StorageMap, +) +from algokit_utils.models.transaction import SendParams +from algokit_utils.transactions.transaction_composer import ( + AppCallMethodCall, + AppCallParams, + AppDeleteMethodCall, + AppMethodCallTransactionArgument, + AppUpdateMethodCall, + AppUpdateParams, + BuiltTransactions, + PaymentParams, +) +from algokit_utils.transactions.transaction_sender import SendAppTransactionResult, SendSingleTransactionResult + +if TYPE_CHECKING: + from collections.abc import Callable + + from algosdk.atomic_transaction_composer import TransactionSigner + + from algokit_utils.applications.app_manager import ( + AppManager, + BoxIdentifier, + BoxReference, + TealTemplateParams, + ) + from algokit_utils.models.abi import ABIStruct, ABIType, ABIValue + from algokit_utils.models.amount import AlgoAmount + from algokit_utils.protocols.application import AlgorandClientProtocol + from algokit_utils.transactions.transaction_composer import TransactionComposer + +# TEAL opcodes for constant blocks +BYTE_CBLOCK = 38 # bytecblock opcode +INT_CBLOCK = 32 # intcblock opcode + +T = TypeVar("T") # For generic return type in _handle_call_errors + + +def get_constant_block_offset(program: bytes) -> int: # noqa: C901 + """Calculate the offset after constant blocks in TEAL program. + + Args: + program: The compiled TEAL program bytes + + Returns: + The maximum offset after bytecblock/intcblock operations + """ + bytes_list = list(program) + program_size = len(bytes_list) + + # Remove version byte + bytes_list.pop(0) + + # Track offsets + bytecblock_offset: int | None = None + intcblock_offset: int | None = None + + while bytes_list: + # Get current byte + byte = bytes_list.pop(0) + + # Check if byte is a constant block opcode + if byte in (BYTE_CBLOCK, INT_CBLOCK): + is_bytecblock = byte == BYTE_CBLOCK + + # Get number of values in constant block + if not bytes_list: + break + values_remaining = bytes_list.pop(0) + + # Process each value in the block + for _ in range(values_remaining): + if is_bytecblock: + # For bytecblock, next byte is length of element + if not bytes_list: + break + length = bytes_list.pop(0) + # Remove the bytes for this element + bytes_list = bytes_list[length:] + else: + # For intcblock, read until we find end of uvarint (MSB not set) + while bytes_list: + byte = bytes_list.pop(0) + if not (byte & 0x80): # Check if MSB is not set + break + + # Update appropriate offset + if is_bytecblock: + bytecblock_offset = program_size - len(bytes_list) - 1 + else: + intcblock_offset = program_size - len(bytes_list) - 1 + + # If next byte isn't a constant block opcode, we're done + if not bytes_list or bytes_list[0] not in (BYTE_CBLOCK, INT_CBLOCK): + break + + # Return maximum offset + return max(bytecblock_offset or 0, intcblock_offset or 0) + + +@dataclass(kw_only=True, frozen=True) +class AppClientCompilationParams: + deploy_time_params: TealTemplateParams | None = None + updatable: bool | None = None + deletable: bool | None = None + + +@dataclass(kw_only=True, frozen=True) +class ExposedLogicErrorDetails: + is_clear_state_program: bool = False + approval_source_map: SourceMap | None = None + clear_source_map: SourceMap | None = None + program: bytes | None = None + approval_source_info: ProgramSourceInfo | None = None + clear_source_info: ProgramSourceInfo | None = None + + +@dataclass(kw_only=True, frozen=True) +class AppClientParams: + """Full parameters for creating an app client""" + + app_spec: ( + Arc56Contract | ApplicationSpecification | str + ) # Using string quotes since these types may be defined elsewhere + algorand: AlgorandClientProtocol # Using string quotes since this type may be defined elsewhere + app_id: int + app_name: str | None = None + default_sender: str | bytes | None = None # Address can be string or bytes + default_signer: TransactionSigner | None = None + approval_source_map: SourceMap | None = None + clear_source_map: SourceMap | None = None + + +@dataclass(kw_only=True, frozen=True) +class AppClientCompilationResult: + approval_program: bytes + clear_state_program: bytes + compiled_approval: CompiledTeal | None = None + compiled_clear: CompiledTeal | None = None + + +@dataclass(kw_only=True, frozen=True) +class CommonTxnParams: + sender: str + signer: TransactionSigner | None = None + rekey_to: str | None = None + note: bytes | None = None + lease: bytes | None = None + static_fee: AlgoAmount | None = None + extra_fee: AlgoAmount | None = None + max_fee: AlgoAmount | None = None + validity_window: int | None = None + first_valid_round: int | None = None + last_valid_round: int | None = None + + +@dataclass(kw_only=True) +class FundAppAccountParams: + sender: str | None = None + signer: TransactionSigner | None = None + rekey_to: str | None = None + note: bytes | None = None + lease: bytes | None = None + static_fee: AlgoAmount | None = None + extra_fee: AlgoAmount | None = None + max_fee: AlgoAmount | None = None + validity_window: int | None = None + first_valid_round: int | None = None + last_valid_round: int | None = None + amount: AlgoAmount + close_remainder_to: str | None = None + max_rounds_to_wait: int | None = None + suppress_log: bool | None = None + populate_app_call_resources: bool | None = None + on_complete: algosdk.transaction.OnComplete | None = None + + +@dataclass(kw_only=True) +class AppClientCallParams: + method: str | None = None # If calling ABI method, name or signature + args: list | None = None # Arguments to pass to the method + boxes: list | None = None # Box references to load + accounts: list[str] | None = None # Account addresses to load + apps: list[int] | None = None # App IDs to load + assets: list[int] | None = None # Asset IDs to load + lease: (str | bytes) | None = None # Optional lease + sender: str | None = None # Optional sender account + note: (bytes | dict | str) | None = None # Transaction note + send_params: dict | None = None # Parameters to control transaction sending + + +@dataclass(kw_only=True, frozen=True) +class AppClientMethodCallParams: + method: str + args: list[ABIValue | ABIStruct | AppMethodCallTransactionArgument | None] | None = None + account_references: list[str] | None = None + app_references: list[int] | None = None + asset_references: list[int] | None = None + box_references: list[BoxReference | BoxIdentifier] | None = None + extra_fee: AlgoAmount | None = None + first_valid_round: int | None = None + lease: bytes | None = None + max_fee: AlgoAmount | None = None + note: bytes | None = None + rekey_to: str | None = None + sender: str | None = None + signer: TransactionSigner | None = None + static_fee: AlgoAmount | None = None + validity_window: int | None = None + last_valid_round: int | None = None + on_complete: algosdk.transaction.OnComplete | None = None + + +@dataclass(kw_only=True, frozen=True) +class AppClientMethodCallWithCompilationParams(AppClientMethodCallParams, AppClientCompilationParams): + """Combined parameters for method calls with compilation""" + + +@dataclass(kw_only=True, frozen=True) +class AppClientMethodCallWithSendParams(AppClientMethodCallParams, SendParams): + """Combined parameters for method calls with send options""" + + +@dataclass(kw_only=True, frozen=True) +class AppClientMethodCallWithCompilationAndSendParams( + AppClientMethodCallParams, AppClientCompilationParams, SendParams +): + """Combined parameters for method calls with compilation and send options""" + + +@dataclass(kw_only=True, frozen=True) +class AppClientBareCallParams: + signer: TransactionSigner | None = None + rekey_to: str | None = None + lease: bytes | None = None + static_fee: AlgoAmount | None = None + extra_fee: AlgoAmount | None = None + max_fee: AlgoAmount | None = None + validity_window: int | None = None + first_valid_round: int | None = None + last_valid_round: int | None = None + sender: str | None = None + note: bytes | None = None + args: list[bytes] | None = None + account_references: list[str] | None = None + app_references: list[int] | None = None + asset_references: list[int] | None = None + box_references: list[BoxReference | BoxIdentifier] | None = None + + +@dataclass(kw_only=True, frozen=True) +class CallOnComplete: + on_complete: algosdk.transaction.OnComplete + + +@dataclass(kw_only=True, frozen=True) +class AppClientBareCallWithCompilationParams(AppClientBareCallParams, AppClientCompilationParams): + """Combined parameters for bare calls with compilation""" + + +@dataclass(kw_only=True, frozen=True) +class AppClientBareCallWithSendParams(AppClientBareCallParams, SendParams): + """Combined parameters for bare calls with send options""" + + +@dataclass(kw_only=True, frozen=True) +class AppClientBareCallWithCompilationAndSendParams(AppClientBareCallParams, AppClientCompilationParams, SendParams): + """Combined parameters for bare calls with compilation and send options""" + + +@dataclass(kw_only=True, frozen=True) +class AppClientBareCallWithCallOnCompleteParams(AppClientBareCallParams, CallOnComplete): + """Combined parameters for bare calls with an OnComplete value""" + + +@dataclass(kw_only=True, frozen=True) +class ResolveAppClientByNetwork: + app_spec: Arc56Contract | ApplicationSpecification | str + algorand: AlgorandClientProtocol + app_name: str | None = None + default_sender: str | bytes | None = None + default_signer: TransactionSigner | None = None + approval_source_map: SourceMap | None = None + clear_source_map: SourceMap | None = None + + +@dataclass(kw_only=True, frozen=True) +class AppSourceMaps: + approval_source_map: SourceMap | None = None + clear_source_map: SourceMap | None = None + + +class _AppClientStateMethodsProtocol(Protocol): + def get_all(self) -> dict[str, Any]: ... + + def get_value(self, name: str, app_state: dict[str, AppState] | None = None) -> ABIValue | None: ... + + def get_map_value(self, map_name: str, key: bytes | Any, app_state: dict[str, AppState] | None = None) -> Any: ... # noqa: ANN401 + + def get_map(self, map_name: str) -> dict[str, ABIValue]: ... + + +class _AppClientStateMethods(_AppClientStateMethodsProtocol): + def __init__( + self, + *, + get_all: Callable[[], dict[str, Any]], + get_value: Callable[[str, dict[str, AppState] | None], ABIValue | None], + get_map_value: Callable[[str, bytes | Any, dict[str, AppState] | None], Any], + get_map: Callable[[str], dict[str, ABIValue]], + ) -> None: + self._get_all = get_all + self._get_value = get_value + self._get_map_value = get_map_value + self._get_map = get_map + + def get_all(self) -> dict[str, Any]: + return self._get_all() + + def get_value(self, name: str, app_state: dict[str, AppState] | None = None) -> ABIValue | None: + return self._get_value(name, app_state) + + def get_map_value(self, map_name: str, key: bytes | Any, app_state: dict[str, AppState] | None = None) -> Any: # noqa: ANN401 + return self._get_map_value(map_name, key, app_state) + + def get_map(self, map_name: str) -> dict[str, ABIValue]: + return self._get_map(map_name) + + +class _AppClientStateAccessor: + def __init__(self, client: AppClient) -> None: + self._client = client + self._algorand = client._algorand + self._app_id = client._app_id + self._app_spec = client._app_spec + + def local_state(self, address: str) -> _AppClientStateMethodsProtocol: + """Methods to access local state for the current app for a given address""" + return self._get_state_methods( + state_getter=lambda: self._algorand.app.get_local_state(self._app_id, address), + key_getter=lambda: self._app_spec.state.keys.get("local", {}), + map_getter=lambda: self._app_spec.state.maps.get("local", {}), + ) + + @property + def global_state(self) -> _AppClientStateMethodsProtocol: + """Methods to access global state for the current app""" + return self._get_state_methods( + state_getter=lambda: self._algorand.app.get_global_state(self._app_id), + key_getter=lambda: self._app_spec.state.keys.get("global", {}), + map_getter=lambda: self._app_spec.state.maps.get("global", {}), + ) + + # @property + # def box(self) -> AppClientStateMethods: + # """Methods to access box storage for the current app""" + # return self._get_state_methods( + # state_getter=lambda: self._algorand.app.get_box_state(self._app_id), + # key_getter=lambda: self._app_spec.state.keys.get("box", {}), + # map_getter=lambda: self._app_spec.state.maps.get("box", {}), + # ) + + def _get_state_methods( # noqa: C901 + self, + state_getter: Callable[[], dict[str, AppState]], + key_getter: Callable[[], dict[str, StorageKey]], + map_getter: Callable[[], dict[str, StorageMap]], + ) -> _AppClientStateMethodsProtocol: + def get_all() -> dict[str, Any]: + state = state_getter() + keys = key_getter() + return {key: get_value(key, state) for key in keys} + + def get_value(name: str, app_state: dict[str, AppState] | None = None) -> ABIValue | None: + state = app_state or state_getter() + key_info = key_getter()[name] + value = next((s for s in state.values() if s.key_base64 == key_info.key), None) + + if value and value.value_raw: + return get_abi_decoded_value(value.value_raw, key_info.value_type, self._app_spec.structs) + + return None + + def get_map_value(map_name: str, key: bytes | Any, app_state: dict[str, AppState] | None = None) -> Any: # noqa: ANN401 + state = app_state or state_getter() + metadata = map_getter()[map_name] + + prefix = bytes(metadata.prefix or "", "base64") + encoded_key = get_abi_encoded_value(key, metadata.key_type, self._app_spec.structs) + full_key = base64.b64encode(prefix + encoded_key).decode("utf-8") + value = next((s for s in state.values() if s.key_base64 == full_key), None) + if value and value.value_raw: + return get_abi_decoded_value(value.value_raw, metadata.value_type, self._app_spec.structs) + return None + + def get_map(map_name: str) -> dict[str, ABIValue]: + state = state_getter() + metadata = map_getter()[map_name] + + prefix = metadata.prefix or "" + + prefixed_state = {k: v for k, v in state.items() if k.startswith(prefix)} + + decoded_map = {} + + for key_encoded, value in prefixed_state.items(): + key_bytes = key_encoded[len(prefix) :] + try: + decoded_key = get_abi_decoded_value(key_bytes, metadata.key_type, self._app_spec.structs) + except Exception as e: + raise ValueError(f"Failed to decode key {key_encoded}") from e + + try: + if value and value.value_raw: + decoded_value = get_abi_decoded_value( + value.value_raw, metadata.value_type, self._app_spec.structs + ) + else: + decoded_value = get_abi_decoded_value(value.value, metadata.value_type, self._app_spec.structs) + except Exception as e: + raise ValueError(f"Failed to decode value {value}") from e + + decoded_map[str(decoded_key)] = decoded_value + + return decoded_map + + return _AppClientStateMethods( + get_all=get_all, + get_value=get_value, + get_map_value=get_map_value, + get_map=get_map, + ) + + def get_local_state(self, address: str) -> dict[str, AppState]: + return self._algorand.app.get_local_state(self._app_id, address) + + def get_global_state(self) -> dict[str, AppState]: + return self._algorand.app.get_global_state(self._app_id) + + +class _AppClientBareParamsAccessor: + def __init__(self, client: AppClient) -> None: + self._client = client + self._algorand = client._algorand + self._app_id = client._app_id + self._app_spec = client._app_spec + + def _get_bare_params( + self, params: dict[str, Any] | None, on_complete: algosdk.transaction.OnComplete + ) -> dict[str, Any]: + """Get bare parameters for application calls. + + Args: + params: The parameters to process + on_complete: The OnComplete value for the transaction + + Returns: + The processed parameters with defaults filled in + """ + params = params or {} + sender = self._client._get_sender(params.get("sender")) + return { + **params, + "app_id": self._app_id, + "sender": sender, + "signer": self._client._get_signer(params.get("sender"), params.get("signer")), + "on_complete": on_complete, + } + + def update(self, params: AppClientBareCallWithCompilationAndSendParams | None = None) -> AppUpdateParams: + call_params: AppUpdateParams = AppUpdateParams( + **self._get_bare_params(params.__dict__ if params else {}, OnComplete.UpdateApplicationOC) + ) + return call_params + + def opt_in(self, params: AppClientBareCallWithSendParams | None = None) -> AppCallParams: + call_params: AppCallParams = AppCallParams(**self._get_bare_params(params.__dict__, OnComplete.OptInOC)) + return call_params + + def delete(self, params: AppClientBareCallWithSendParams) -> AppCallParams: + call_params: AppCallParams = AppCallParams( + **self._get_bare_params(params.__dict__, OnComplete.DeleteApplicationOC) + ) + return call_params + + def clear_state(self, params: AppClientBareCallWithSendParams) -> AppCallParams: + call_params: AppCallParams = AppCallParams(**self._get_bare_params(params.__dict__, OnComplete.ClearStateOC)) + return call_params + + def close_out(self, params: AppClientBareCallWithSendParams) -> AppCallParams: + call_params: AppCallParams = AppCallParams(**self._get_bare_params(params.__dict__, OnComplete.CloseOutOC)) + return call_params + + def call(self, params: AppClientBareCallWithCallOnCompleteParams) -> AppCallParams: + call_params: AppCallParams = AppCallParams(**self._get_bare_params(params.__dict__, OnComplete.NoOpOC)) + return call_params + + +class _AppClientMethodCallParamsAccessor: + def __init__(self, client: AppClient) -> None: + self._client = client + self._algorand = client._algorand + self._app_id = client._app_id + self._app_spec = client._app_spec + self._bare_params_accessor = _AppClientBareParamsAccessor(client) + + @property + def bare(self) -> _AppClientBareParamsAccessor: + return self._bare_params_accessor + + def fund_app_account(self, params: FundAppAccountParams) -> PaymentParams: + def random_note() -> bytes: + return base64.b64encode(os.urandom(16)) + + return PaymentParams( + sender=self._client._get_sender(params.sender), + signer=self._client._get_signer(params.sender, params.signer), + receiver=self._client.app_address, + amount=params.amount, + rekey_to=params.rekey_to, + note=params.note or random_note(), + lease=params.lease, + static_fee=params.static_fee, + extra_fee=params.extra_fee, + max_fee=params.max_fee, + validity_window=params.validity_window, + first_valid_round=params.first_valid_round, + last_valid_round=params.last_valid_round, + close_remainder_to=params.close_remainder_to, + ) + + def opt_in(self, params: AppClientMethodCallParams) -> AppCallMethodCall: + input_params = self._get_abi_params(params.__dict__, on_complete=algosdk.transaction.OnComplete.OptInOC) + return AppCallMethodCall(**input_params) + + def call(self, params: AppClientMethodCallParams) -> AppCallMethodCall: + input_params = self._get_abi_params(params.__dict__, on_complete=algosdk.transaction.OnComplete.NoOpOC) + return AppCallMethodCall(**input_params) + + def delete(self, params: AppClientMethodCallParams) -> AppDeleteMethodCall: + input_params = self._get_abi_params( + params.__dict__, on_complete=algosdk.transaction.OnComplete.DeleteApplicationOC + ) + return AppDeleteMethodCall(**input_params) + + def update( + self, params: AppClientMethodCallParams | AppClientMethodCallWithCompilationAndSendParams + ) -> AppUpdateMethodCall: + compile_params = ( + self._client.compile( + app_spec=self._client.app_spec, + app_manager=self._algorand.app, + deploy_time_params=params.deploy_time_params, + updatable=params.updatable, + deletable=params.deletable, + ).__dict__ + if isinstance(params, AppClientMethodCallWithCompilationAndSendParams) + else {} + ) + + input_params = { + **self._get_abi_params(params.__dict__, on_complete=algosdk.transaction.OnComplete.UpdateApplicationOC), + **compile_params, + } + # Filter input_params to include only fields valid for AppUpdateMethodCall + app_update_method_call_fields = {field.name for field in fields(AppUpdateMethodCall)} + filtered_input_params = {k: v for k, v in input_params.items() if k in app_update_method_call_fields} + return AppUpdateMethodCall(**filtered_input_params) + + def close_out(self, params: AppClientMethodCallParams) -> AppCallMethodCall: + input_params = self._get_abi_params(params.__dict__, on_complete=algosdk.transaction.OnComplete.CloseOutOC) + return AppCallMethodCall(**input_params) + + def _get_abi_params(self, params: dict[str, Any], on_complete: algosdk.transaction.OnComplete) -> dict[str, Any]: + input_params = copy.deepcopy(params) + + input_params["app_id"] = self._app_id + input_params["on_complete"] = on_complete + + input_params["sender"] = self._client._get_sender(params["sender"]) + input_params["signer"] = self._client._get_signer(params["sender"], params["signer"]) + + if params.get("method"): + input_params["method"] = get_arc56_method(params["method"], self._app_spec) + if params.get("args"): + input_params["args"] = self._client._get_abi_args_with_default_values( + method_name_or_signature=params["method"], + args=params["args"], + sender=self._client._get_sender(input_params["sender"]), + ) + + return input_params + + +class _AppClientBareCreateTransactionMethods: + def __init__(self, client: AppClient) -> None: + self._client = client + self._algorand = client._algorand + + def update(self, params: AppClientBareCallWithCompilationAndSendParams) -> Transaction: + return self._algorand.create_transaction.app_update(self._client.params.bare.update(params)) + + def opt_in(self, params: AppClientBareCallWithSendParams) -> Transaction: + return self._algorand.create_transaction.app_call(self._client.params.bare.opt_in(params)) + + def delete(self, params: AppClientBareCallWithSendParams) -> Transaction: + return self._algorand.create_transaction.app_call(self._client.params.bare.delete(params)) + + def clear_state(self, params: AppClientBareCallWithSendParams) -> Transaction: + return self._algorand.create_transaction.app_call(self._client.params.bare.clear_state(params)) + + def close_out(self, params: AppClientBareCallWithSendParams) -> Transaction: + return self._algorand.create_transaction.app_call(self._client.params.bare.close_out(params)) + + def call(self, params: AppClientBareCallWithCallOnCompleteParams) -> Transaction: + return self._algorand.create_transaction.app_call(self._client.params.bare.call(params)) + + +class _AppClientMethodCallTransactionCreator: + def __init__(self, client: AppClient) -> None: + self._client = client + self._algorand = client._algorand + self._app_id = client._app_id + self._app_spec = client._app_spec + self._bare_create_transaction_methods = _AppClientBareCreateTransactionMethods(client) + + @property + def bare(self) -> _AppClientBareCreateTransactionMethods: + return self._bare_create_transaction_methods + + def fund_app_account(self, params: FundAppAccountParams) -> Transaction: + return self._algorand.create_transaction.payment(self._client.params.fund_app_account(params)) + + def opt_in(self, params: AppClientMethodCallParams) -> BuiltTransactions: + return self._algorand.create_transaction.app_call_method_call(self._client.params.opt_in(params)) + + def update(self, params: AppClientMethodCallParams) -> BuiltTransactions: + return self._algorand.create_transaction.app_update_method_call(self._client.params.update(params)) + + def delete(self, params: AppClientMethodCallParams) -> BuiltTransactions: + return self._algorand.create_transaction.app_delete_method_call(self._client.params.delete(params)) + + def close_out(self, params: AppClientMethodCallParams) -> BuiltTransactions: + return self._algorand.create_transaction.app_call_method_call(self._client.params.close_out(params)) + + def call(self, params: AppClientMethodCallParams) -> BuiltTransactions: + return self._algorand.create_transaction.app_call_method_call(self._client.params.call(params)) + + +class _AppClientBareSendAccessor: + def __init__(self, client: AppClient) -> None: + self._client = client + self._algorand = client._algorand + self._app_id = client._app_id + self._app_spec = client._app_spec + + def update( + self, + params: AppClientBareCallWithCompilationAndSendParams, + ) -> SendAppTransactionResult: + """Send an application update transaction. + + Args: + params: The parameters for the update call + compilation: Optional compilation parameters + max_rounds_to_wait: The maximum number of rounds to wait for confirmation + suppress_log: Whether to suppress log output + populate_app_call_resources: Whether to populate app call resources + + Returns: + The result of sending the transaction + """ + compiled = self._client.compile_and_persist_sourcemaps( + params.deploy_time_params, params.updatable, params.deletable + ) + bare_params = self._client.params.bare.update(params) + bare_params.__setattr__("approval_program", bare_params.approval_program or compiled.compiled_approval) + bare_params.__setattr__("clear_state_program", bare_params.clear_state_program or compiled.compiled_clear) + call_result = self._client._handle_call_errors(lambda: self._algorand.send.app_update(bare_params)) + return SendAppTransactionResult(**{**call_result.__dict__, **(compiled.__dict__ if compiled else {})}) + + def opt_in(self, params: AppClientBareCallWithSendParams) -> SendAppTransactionResult: + return self._client._handle_call_errors( # type: ignore[no-any-return] + lambda: self._algorand.send.app_call(self._client.params.bare.opt_in(params)) + ) + + def delete(self, params: AppClientBareCallWithSendParams) -> SendAppTransactionResult: + return self._client._handle_call_errors( # type: ignore[no-any-return] + lambda: self._algorand.send.app_call(self._client.params.bare.delete(params)) + ) + + def clear_state(self, params: AppClientBareCallWithSendParams) -> SendAppTransactionResult: + return self._client._handle_call_errors( # type: ignore[no-any-return] + lambda: self._algorand.send.app_call(self._client.params.bare.clear_state(params)) + ) + + def close_out(self, params: AppClientBareCallWithSendParams) -> SendAppTransactionResult: + return self._client._handle_call_errors( # type: ignore[no-any-return] + lambda: self._algorand.send.app_call(self._client.params.bare.close_out(params)) + ) + + def call(self, params: AppClientBareCallWithCallOnCompleteParams) -> SendAppTransactionResult: + return self._client._handle_call_errors( # type: ignore[no-any-return] + lambda: self._algorand.send.app_call(self._client.params.bare.call(params)) + ) + + +class _AppClientSendAccessor: + def __init__(self, client: AppClient) -> None: + self._client = client + self._algorand = client._algorand + self._app_id = client._app_id + self._app_spec = client._app_spec + self._bare_send_accessor = _AppClientBareSendAccessor(client) + + @property + def bare(self) -> _AppClientBareSendAccessor: + return self._bare_send_accessor + + def fund_app_account(self, params: FundAppAccountParams) -> SendSingleTransactionResult: + return self._client._handle_call_errors( # type: ignore[no-any-return] + lambda: self._algorand.send.payment(self._client.params.fund_app_account(params)) + ) + + def opt_in(self, params: AppClientMethodCallWithSendParams) -> SendAppTransactionResult: + return self._client._handle_call_errors( # type: ignore[no-any-return] + lambda: self._algorand.send.app_call_method_call(self._client.params.opt_in(params)) + ) + + def delete(self, params: AppClientMethodCallWithSendParams) -> SendAppTransactionResult: + return self._client._handle_call_errors( # type: ignore[no-any-return] + lambda: self._algorand.send.app_delete_method_call(self._client.params.delete(params)) + ) + + def update(self, params: AppClientMethodCallWithCompilationAndSendParams) -> SendAppTransactionResult: + return self._client._handle_call_errors( # type: ignore[no-any-return] + lambda: self._algorand.send.app_update_method_call(self._client.params.update(params)) + ) + + def close_out(self, params: AppClientMethodCallWithSendParams) -> SendAppTransactionResult: + return self._client._handle_call_errors( # type: ignore[no-any-return] + lambda: self._algorand.send.app_call_method_call(self._client.params.close_out(params)) + ) + + def call(self, params: AppClientMethodCallWithSendParams) -> SendAppTransactionResult: + is_read_only_call = params.on_complete == algosdk.transaction.OnComplete.NoOpOC or ( + not params.on_complete and get_arc56_method(params.method, self._app_spec).method.readonly + ) + + if is_read_only_call: + method_call_to_simulate = self._algorand.new_group().add_app_call_method_call( + self._client.params.call(params) + ) + + simulate_response = self._client._handle_call_errors( + lambda: method_call_to_simulate.simulate( + allow_unnamed_resources=params.populate_app_call_resources or True, + skip_signatures=True, + allow_more_logs=True, + allow_empty_signatures=True, + extra_opcode_budget=None, + exec_trace_config=None, + round=None, + fix_signers=None, # TODO: double check on whether algosdk py even has this param + ) + ) + + return SendAppTransactionResult( + tx_ids=simulate_response.tx_ids, + transactions=simulate_response.transactions, + transaction=simulate_response.transactions[-1], + confirmation=simulate_response.confirmations[-1] if simulate_response.confirmations else b"", + confirmations=simulate_response.confirmations, + group_id=simulate_response.group_id or "", + returns=simulate_response.returns, + return_value=simulate_response.returns[-1], + ) + + return self._client._handle_call_errors( + lambda: self._algorand.send.app_call_method_call(self._client.params.call(params)) + ) + + +class AppClient: + def __init__(self, params: AppClientParams) -> None: + self._app_id = params.app_id + self._app_spec = self.normalise_app_spec(params.app_spec) + self._algorand = params.algorand + self._app_address = algosdk.logic.get_application_address(self._app_id) + self._app_name = params.app_name or self._app_spec.name + self._default_sender = params.default_sender + self._default_signer = params.default_signer + self._approval_source_map = params.approval_source_map + self._clear_source_map = params.clear_source_map + self._state_accessor = _AppClientStateAccessor(self) + self._params_accessor = _AppClientMethodCallParamsAccessor(self) + self._send_accessor = _AppClientSendAccessor(self) + self._create_transaction_accessor = _AppClientMethodCallTransactionCreator(self) + + @property + def app_id(self) -> int: + return self._app_id + + @property + def app_address(self) -> str: + return self._app_address + + @property + def app_name(self) -> str: + return self._app_name + + @property + def app_spec(self) -> Arc56Contract: + return self._app_spec + + @property + def state(self) -> _AppClientStateAccessor: + return self._state_accessor + + @property + def params(self) -> _AppClientMethodCallParamsAccessor: + return self._params_accessor + + @property + def send(self) -> _AppClientSendAccessor: + return self._send_accessor + + @property + def create_transaction(self) -> _AppClientMethodCallTransactionCreator: + return self._create_transaction_accessor + + @staticmethod + def normalise_app_spec(app_spec: Arc56Contract | ApplicationSpecification | str) -> Arc56Contract: + if isinstance(app_spec, str): + spec = json.loads(app_spec) + if "hints" in spec: + spec = ApplicationSpecification.from_json(app_spec) + else: + spec = app_spec + + if isinstance(spec, Arc56Contract): + return spec + + elif isinstance(spec, ApplicationSpecification): + # Convert ARC-32 to ARC-56 + from algokit_utils.applications.utils import arc32_to_arc56 + + return arc32_to_arc56(spec) + elif isinstance(spec, dict): + # normalize field names to lowercase to python camel + return Arc56Contract.from_json(spec) + else: + raise ValueError("Invalid app spec format") + + @staticmethod + def from_network( + app_spec: Arc56Contract | ApplicationSpecification | str, + algorand: AlgorandClientProtocol, + app_name: str | None = None, + default_sender: str | bytes | None = None, + default_signer: TransactionSigner | None = None, + approval_source_map: SourceMap | None = None, + clear_source_map: SourceMap | None = None, + ) -> AppClient: + network = algorand.client.network() + app_spec = AppClient.normalise_app_spec(app_spec) + network_names = [network.genesis_hash] + + if network.is_local_net: + network_names.append("localnet") + if network.is_main_net: + network_names.append("mainnet") + if network.is_test_net: + network_names.append("testnet") + + available_app_spec_networks = list(app_spec.networks.keys()) if app_spec.networks else [] + network_index = next((i for i, n in enumerate(available_app_spec_networks) if n in network_names), None) + + if network_index is None: + raise Exception(f"No app ID found for network {json.dumps(network_names)} in the app spec") + + app_id = app_spec.networks[available_app_spec_networks[network_index]]["app_id"] # type: ignore[index] + + return AppClient( + AppClientParams( + app_id=app_id, + app_spec=app_spec, + algorand=algorand, + app_name=app_name, + default_sender=default_sender, + default_signer=default_signer, + approval_source_map=approval_source_map, + clear_source_map=clear_source_map, + ) + ) + + @staticmethod + def compile( + app_spec: Arc56Contract, + app_manager: AppManager, + deploy_time_params: TealTemplateParams | None = None, + updatable: bool | None = None, + deletable: bool | None = None, + ) -> AppClientCompilationResult: + def is_base64(s: str) -> bool: + try: + return base64.b64encode(base64.b64decode(s)).decode() == s + except Exception: + return False + + if not app_spec.source: + if not app_spec.byte_code or not app_spec.byte_code.get("approval") or not app_spec.byte_code.get("clear"): + raise ValueError(f"Attempt to compile app {app_spec.name} without source or byte_code") + + return AppClientCompilationResult( + approval_program=base64.b64decode(app_spec.byte_code.get("approval", "")), + clear_state_program=base64.b64decode(app_spec.byte_code.get("clear", "")), + ) + + approval_source = app_spec.source.get("approval", "") + approval_template: str = ( + base64.b64decode(approval_source).decode("utf-8") if is_base64(approval_source) else approval_source + ) + compiled_approval = app_manager.compile_teal_template( + approval_template, + template_params=deploy_time_params, + deployment_metadata=( + {"updatable": updatable or False, "deletable": deletable or False} + if updatable is not None or deletable is not None + else None + ), + ) + + clear_source = app_spec.source.get("clear", "") + clear_template: str = ( + base64.b64decode(clear_source).decode("utf-8") if is_base64(clear_source) else clear_source + ) + compiled_clear = app_manager.compile_teal_template( + clear_template, + template_params=deploy_time_params, + ) + + # TODO: Add invocation of persisting sourcemaps + return AppClientCompilationResult( + approval_program=compiled_approval.compiled_base64_to_bytes, + compiled_approval=compiled_approval, + clear_state_program=compiled_clear.compiled_base64_to_bytes, + compiled_clear=compiled_clear, + ) + + @staticmethod + def expose_logic_error_static( # noqa: C901 + e: Exception, app_spec: Arc56Contract, details: ExposedLogicErrorDetails + ) -> Exception: + """Takes an error that may include a logic error and re-exposes it with source info.""" + source_map = details.clear_source_map if details.is_clear_state_program else details.approval_source_map + + error_details = parse_logic_error(str(e)) + if not error_details: + return e + + # The PC value to find in the ARC56 SourceInfo + arc56_pc = error_details["pc"] + + program_source_info = ( + details.clear_source_info if details.is_clear_state_program else details.approval_source_info + ) + + # The offset to apply to the PC if using the cblocks pc offset method + cblocks_offset = 0 + + # If the program uses cblocks offset, then we need to adjust the PC accordingly + if program_source_info and program_source_info.pc_offset_method == "cblocks": + if not details.program: + raise Exception("Program bytes are required to calculate the ARC56 cblocks PC offset") + + cblocks_offset = get_constant_block_offset(details.program) + arc56_pc = error_details["pc"] - cblocks_offset + + # Find the source info for this PC and get the error message + source_info = None + if program_source_info and program_source_info.source_info: + source_info = next( + (s for s in program_source_info.source_info if isinstance(s, SourceInfoDetail) and arc56_pc in s.pc), + None, + ) + error_message = source_info.error_message if source_info else None + + # If we have the source we can display the TEAL in the error message + if hasattr(app_spec, "source"): + program_source = ( + (app_spec.source.get("clear") if details.is_clear_state_program else app_spec.source.get("approval")) + if app_spec.source + else None + ) + custom_get_line_for_pc = None + + def get_line_for_pc(input_pc: int) -> int | None: + if not program_source_info: + return None + teal = [line.teal for line in program_source_info.source_info if input_pc - cblocks_offset in line.pc] + return teal[0] if teal else None + + if not source_map: + custom_get_line_for_pc = get_line_for_pc + + if program_source: + e = LogicError( + logic_error_str=str(e), + program=program_source, + source_map=source_map, + transaction_id=error_details["transaction_id"], + message=error_details["message"], + pc=error_details["pc"], + logic_error=e, + get_line_for_pc=custom_get_line_for_pc, + traces=None, + ) + + if error_message: + import re + + app_id = re.search(r"(?<=app=)\d+", str(e)) + tx_id = re.search(r"(?<=transaction )\S+(?=:)", str(e)) + error = Exception( + f"Runtime error when executing {app_spec.name} " + f"(appId: {app_id.group() if app_id else ''}) in transaction " + f"{tx_id.group() if tx_id else ''}: {error_message}" + ) + error.__cause__ = e + return error + + return e + + # NOTE: No method overloads hence slightly different name, in TS its both instance/static methods named 'compile' + def compile_and_persist_sourcemaps( + self, + deploy_time_params: TealTemplateParams | None = None, + updatable: bool | None = None, + deletable: bool | None = None, + ) -> AppClientCompilationResult: + result = AppClient.compile(self._app_spec, self._algorand.app, deploy_time_params, updatable, deletable) + + if result.compiled_approval: + self._approval_source_map = result.compiled_approval.source_map + if result.compiled_clear: + self._clear_source_map = result.compiled_clear.source_map + + return result + + def clone( + self, + app_name: str | None = None, + default_sender: str | bytes | None = None, + default_signer: TransactionSigner | None = None, + approval_source_map: SourceMap | None = None, + clear_source_map: SourceMap | None = None, + ) -> AppClient: + return AppClient( + AppClientParams( + app_id=self._app_id, + algorand=self._algorand, + app_spec=self._app_spec, + app_name=app_name or self._app_name, + default_sender=default_sender or self._default_sender, + default_signer=default_signer or self._default_signer, + approval_source_map=approval_source_map or self._approval_source_map, + clear_source_map=clear_source_map or self._clear_source_map, + ) + ) + + def export_source_maps(self) -> AppSourceMaps: + if not self._approval_source_map or not self._clear_source_map: + raise ValueError( + "Unable to export source maps; they haven't been loaded into this client - " + "you need to call create, update, or deploy first" + ) + + return AppSourceMaps( + approval_source_map=self._approval_source_map, + clear_source_map=self._clear_source_map, + ) + + def import_source_maps(self, source_maps: AppSourceMaps) -> None: + if not source_maps.approval_source_map: + raise ValueError("Approval source map is required") + if not source_maps.clear_source_map: + raise ValueError("Clear source map is required") + + if not isinstance(source_maps.approval_source_map, dict | SourceMap): + raise ValueError( + "Approval source map supplied is of invalid type. Must be a raw dict or `algosdk.source_map.SourceMap`" + ) + if not isinstance(source_maps.clear_source_map, dict | SourceMap): + raise ValueError( + "Clear source map supplied is of invalid type. Must be a raw dict or `algosdk.source_map.SourceMap`" + ) + + self._approval_source_map = ( + SourceMap(source_map=source_maps.approval_source_map) + if isinstance(source_maps.approval_source_map, dict) + else source_maps.approval_source_map + ) + self._clear_source_map = ( + SourceMap(source_map=source_maps.clear_source_map) + if isinstance(source_maps.clear_source_map, dict) + else source_maps.clear_source_map + ) + + def get_local_state(self, address: str) -> dict[str, AppState]: + return self._state_accessor.get_local_state(address) + + def get_global_state(self) -> dict[str, AppState]: + return self._state_accessor.get_global_state() + + def get_box_names(self) -> list[BoxName]: + return self._algorand.app.get_box_names(self._app_id) + + def get_box_value(self, name: BoxIdentifier) -> bytes: + return self._algorand.app.get_box_value(self._app_id, name) + + def get_box_value_from_abi_type(self, name: BoxIdentifier, abi_type: ABIType) -> ABIValue: + return self._algorand.app.get_box_value_from_abi_type(self._app_id, name, abi_type) + + def get_box_values(self, filter_func: Callable[[BoxName], bool] | None = None) -> list[BoxValue]: + names = self.get_box_names() + if filter_func: + names = [name for name in names if filter_func(name)] + + # Get values for filtered names + values = self._algorand.app.get_box_values(self.app_id, [name.name_raw for name in names]) + + # Return list of BoxValue objects + return [BoxValue(name=name, value=values[i]) for i, name in enumerate(names)] + + def get_box_values_from_abi_type( + self, abi_type: ABIType, filter_func: Callable[[BoxName], bool] | None = None + ) -> list[BoxABIValue]: + # Get box names and apply filter if provided + names = self.get_box_names() + if filter_func: + names = [name for name in names if filter_func(name)] + + # Get values for filtered names and decode them + values = self._algorand.app.get_box_values_from_abi_type( + self.app_id, [name.name_raw for name in names], abi_type + ) + + # Return list of BoxABIValue objects + return [BoxABIValue(name=name, value=values[i]) for i, name in enumerate(names)] + + def new_group(self) -> TransactionComposer: + return self._algorand.new_group() + + def fund_app_account(self, params: FundAppAccountParams) -> SendSingleTransactionResult: + return self.send.fund_app_account(params) + + def expose_logic_error(self, e: Exception, is_clear_state_program: bool = False) -> Exception: # noqa: FBT001, FBT002 + """Takes an error that may include a logic error from a call to the current app and re-exposes the + error to include source code information via the source map and ARC-56 spec. + + Args: + e: The error to parse + is_clear_state_program: Whether the code was running the clear state program (defaults to approval program) + + Returns: + The new error, or if there was no logic error or source map then the wrapped error with source details + """ + + # Get source info based on program type + source_info = None + if hasattr(self._app_spec, "source_info") and self._app_spec.source_info: + source_info = ( + self._app_spec.source_info.get("clear") + if is_clear_state_program + else self._app_spec.source_info.get("approval") + ) + + pc_offset_method = source_info.pc_offset_method if source_info else None + + program: bytes | None = None + if pc_offset_method == "cblocks": + # TODO: Cache this if we deploy the app and it's not updateable + app_info = self._algorand.app.get_by_id(self.app_id) + program = app_info.clear_state_program if is_clear_state_program else app_info.approval_program + + return AppClient.expose_logic_error_static( + e, + self._app_spec, + ExposedLogicErrorDetails( + is_clear_state_program=is_clear_state_program, + approval_source_map=self._approval_source_map, + clear_source_map=self._clear_source_map, + program=program, + approval_source_info=( + self._app_spec.source_info.get("approval") + if self._app_spec.source_info and hasattr(self._app_spec, "source_info") + else None + ), + clear_source_info=( + self._app_spec.source_info.get("clear") + if self._app_spec.source_info and hasattr(self._app_spec, "source_info") + else None + ), + ), + ) + + def _handle_call_errors(self, call: Callable[[], T]) -> T: + """Make the given call and catch any errors, augmenting with debugging information before re-throwing.""" + try: + return call() + except Exception as e: + raise self.expose_logic_error(e=e) from None + + def _get_sender(self, sender: str | None) -> str: + if not sender and not self._default_sender: + raise Exception( + f"No sender provided and no default sender present in app client for call to app {self.app_name}" + ) + return sender or self._default_sender # type: ignore[return-value] + + def _get_signer(self, sender: str | None, signer: TransactionSigner | None) -> TransactionSigner | None: + return signer or self._default_signer if sender else None + + def _get_bare_params(self, params: dict[str, Any], on_complete: algosdk.transaction.OnComplete) -> dict[str, Any]: + """Get bare parameters for application calls. + + Args: + params: The parameters to process + on_complete: The OnComplete value for the transaction + + Returns: + The processed parameters with defaults filled in + """ + sender = self._get_sender(params.get("sender")) + return { + **params, + "app_id": self._app_id, + "sender": sender, + "signer": self._get_signer(params.get("sender"), params.get("signer")), + "on_complete": on_complete, + } + + def _get_abi_args_with_default_values( # noqa: C901, PLR0912 + self, + *, + method_name_or_signature: str, + args: list[ABIValue | ABIStruct | AppMethodCallTransactionArgument | None] | None, + sender: str, + ) -> list[Any]: + """Get ABI args with default values filled in. + + Args: + method_name_or_signature: Method name or ABI signature + args: Optional list of argument values + sender: Sender address + + Returns: + List of argument values with defaults filled in + + Raises: + ValueError: If required argument is missing or default value lookup fails + """ + method = get_arc56_method(method_name_or_signature, self._app_spec) + result = [] + + for i, method_arg in enumerate(method.arc56_args): + # Get provided arg value if any + arg_value = args[i] if args and i < len(args) else None + + if arg_value is not None: + # Convert struct to tuple if needed + if method_arg.struct and isinstance(arg_value, dict): + arg_value = get_abi_tuple_from_abi_struct( + arg_value, self._app_spec.structs[method_arg.struct], self._app_spec.structs + ) + result.append(arg_value) + continue + + # Handle default value if arg not provided + default_value = method_arg.default_value + if default_value: + match default_value.source: + case "literal": + value_raw = base64.b64decode(default_value.data) + value_type = default_value.type or method_arg.type + result.append(get_abi_decoded_value(value_raw, value_type, self._app_spec.structs)) + + case "method": + # Get method return value + default_method = get_arc56_method(default_value.data, self._app_spec) + empty_args = [None] * len(default_method.args) + call_result = self._algorand.send.app_call_method_call( + AppCallMethodCall( + app_id=self._app_id, + method=algosdk.abi.Method.from_signature(default_value.data), + args=empty_args, + sender=sender, + ) + ) + + if not call_result.return_value: + raise ValueError("Default value method call did not return a value") + + if isinstance(call_result.return_value, dict): + # Convert struct return value to tuple + result.append( + get_abi_tuple_from_abi_struct( + call_result.return_value, + self._app_spec.structs[str(default_method.arc56_returns.type)], + self._app_spec.structs, + ) + ) + else: + result.append(call_result.return_value.return_value) + + case "local" | "global": + # Get state value + state = ( + self.get_global_state() + if default_value.source == "global" + else self.get_local_state(sender) + ) + value = next((s for s in state.values() if s.key_base64 == default_value.data), None) + if not value: + raise ValueError( + f"Key '{default_value.data}' not found in {default_value.source} " + f"storage for argument {method_arg.name or f'arg{i+1}'}" + ) + + if value.value_raw: + value_type = default_value.type or method_arg.type + result.append(get_abi_decoded_value(value.value_raw, value_type, self._app_spec.structs)) + else: + result.append(value.value) + + case "box": + # Get box value + box_name = base64.b64decode(default_value.data) + box_value = self._algorand.app.get_box_value(self._app_id, box_name) + value_type = default_value.type or method_arg.type + result.append(get_abi_decoded_value(box_value, value_type, self._app_spec.structs)) + + elif not algosdk.abi.is_abi_transaction_type(method_arg.type): + # Error if required non-txn arg missing + raise ValueError( + f"No value provided for required argument " + f"{method_arg.name or f'arg{i+1}'} in call to method {method.name}" + ) + + return result + + def _get_abi_params(self, params: dict[str, Any], on_complete: algosdk.transaction.OnComplete) -> dict[str, Any]: + sender = self._get_sender(params.get("sender")) + method = get_arc56_method(params["method"], self._app_spec) + args = self._get_abi_args_with_default_values( + method_name_or_signature=params["method"], args=params.get("args"), sender=sender + ) + return { + **params, + "appId": self._app_id, + "sender": sender, + "signer": self._get_signer(params.get("sender"), params.get("signer")), + "method": method, + "onComplete": on_complete, + "args": args, + } diff --git a/src/algokit_utils/applications/app_deployer.py b/src/algokit_utils/applications/app_deployer.py new file mode 100644 index 00000000..357d808c --- /dev/null +++ b/src/algokit_utils/applications/app_deployer.py @@ -0,0 +1,602 @@ +import base64 +import dataclasses +import json +from dataclasses import dataclass +from typing import Literal + +import algosdk +from algosdk.atomic_transaction_composer import ABIResult, TransactionSigner +from algosdk.logic import get_application_address +from algosdk.transaction import OnComplete +from algosdk.v2client.indexer import IndexerClient + +from algokit_utils._legacy_v2.deploy import ( + AppDeployMetaData, + AppLookup, + AppMetaData, + OnSchemaBreak, + OnUpdate, + OperationPerformed, +) +from algokit_utils.applications.app_manager import AppManager, BoxReference, TealTemplateParams +from algokit_utils.config import config +from algokit_utils.transactions.models import TransactionWrapper +from algokit_utils.transactions.transaction_composer import ( + AppCreateMethodCall, + AppCreateParams, + AppDeleteMethodCall, + AppDeleteParams, + AppUpdateMethodCall, + AppUpdateParams, +) +from algokit_utils.transactions.transaction_sender import ( + AlgorandClientTransactionSender, +) + +APP_DEPLOY_NOTE_DAPP = "algokit_deployer" + +logger = config.logger + + +@dataclass(kw_only=True) +class DeployAppUpdateParams: + """Parameters for an update transaction in app deployment""" + + sender: str + on_complete: OnComplete = OnComplete.UpdateApplicationOC + signer: TransactionSigner | None = None + args: list[bytes] | None = None + note: bytes | None = None + lease: bytes | None = None + rekey_to: str | None = None + account_references: list[str] | None = None + app_references: list[int] | None = None + asset_references: list[int] | None = None + box_references: list[BoxReference] | None = None + + +@dataclass(kw_only=True) +class DeployAppDeleteParams: + """Parameters for a delete transaction in app deployment""" + + sender: str + on_complete: OnComplete = OnComplete.DeleteApplicationOC + signer: TransactionSigner | None = None + note: bytes | None = None + lease: bytes | None = None + rekey_to: str | None = None + account_references: list[str] | None = None + app_references: list[int] | None = None + asset_references: list[int] | None = None + box_references: list[BoxReference] | None = None + + +@dataclass(kw_only=True) +class AppDeployParams: + """Parameters for deploying an app""" + + metadata: AppDeployMetaData + deploy_time_params: TealTemplateParams | None = None + on_schema_break: Literal["replace", "fail", "append"] | OnSchemaBreak = OnSchemaBreak.Fail + on_update: Literal["update", "replace", "fail", "append"] | OnUpdate = OnUpdate.Fail + create_params: AppCreateParams | AppCreateMethodCall + update_params: DeployAppUpdateParams | AppUpdateMethodCall + delete_params: DeployAppDeleteParams | AppDeleteMethodCall + existing_deployments: AppLookup | None = None + ignore_cache: bool = False + max_fee: int | None = None + max_rounds_to_wait: int | None = None + suppress_log: bool = False + + +@dataclass(kw_only=True, frozen=True) +class ConfirmedTransactionResult: + transaction: TransactionWrapper + confirmation: algosdk.v2client.algod.AlgodResponseType + confirmations: list[algosdk.v2client.algod.AlgodResponseType] | None = None + + +@dataclass(kw_only=True, frozen=True) +class AppDeployResult: + operation_performed: OperationPerformed + + # Common fields from AppMetadata + name: str + version: str + created_round: int + updated_round: int + deleted: bool + created_metadata: dict + deletable: bool | None = None + updatable: bool | None = None + + app_id: int | None = None + app_address: str | None = None + transaction: TransactionWrapper | None = None + tx_id: str | None = None + transactions: list[TransactionWrapper] | None = None + tx_ids: list[str] | None = None + confirmation: algosdk.v2client.algod.AlgodResponseType | None = None + confirmations: list[algosdk.v2client.algod.AlgodResponseType] | None = None + compiled_approval: dict | None = None + compiled_clear: dict | None = None + return_value: ABIResult | None = None + delete_return_value: ABIResult | None = None + delete_result: ConfirmedTransactionResult | None = None + + +class AppDeployer: + """Manages deployment and deployment metadata of applications""" + + def __init__( + self, + app_manager: AppManager, + transaction_sender: AlgorandClientTransactionSender, + indexer: IndexerClient | None = None, + ): + self._app_manager = app_manager + self._transaction_sender = transaction_sender + self._indexer = indexer + self._app_lookups: dict[str, AppLookup] = {} + + def _create_deploy_note(self, metadata: AppDeployMetaData) -> bytes: + note = { + "dapp_name": APP_DEPLOY_NOTE_DAPP, + "format": "j", + "data": metadata.__dict__, + } + return json.dumps(note).encode() + + def deploy(self, deployment: AppDeployParams) -> AppDeployResult: + # Create new instances with updated notes + logger.info( + f"Idempotently deploying app \"{deployment.metadata.name}\" from creator " + f"{deployment.create_params.sender} using {len(deployment.create_params.approval_program)} bytes of " + f"{'teal code' if isinstance(deployment.create_params.approval_program, str) else 'AVM bytecode'} and " + f"{len(deployment.create_params.clear_state_program)} bytes of " + f"{'teal code' if isinstance(deployment.create_params.clear_state_program, str) else 'AVM bytecode'}", + suppress_log=deployment.suppress_log, + ) + note = self._create_deploy_note(deployment.metadata) + create_params = dataclasses.replace(deployment.create_params, note=note) + update_params = dataclasses.replace(deployment.update_params, note=note) + + deployment = dataclasses.replace( + deployment, + create_params=create_params, + update_params=update_params, + ) + + # Validate inputs + if ( + deployment.existing_deployments + and deployment.existing_deployments.creator != deployment.create_params.sender + ): + raise ValueError( + f"Received invalid existingDeployments value for creator " + f"{deployment.existing_deployments.creator} when attempting to deploy " + f"for creator {deployment.create_params.sender}" + ) + + if not deployment.existing_deployments and not self._indexer: + raise ValueError( + "Didn't receive an indexer client when this AppManager was created, " + "but also didn't receive an existingDeployments cache - one of them must be provided" + ) + + # Compile code if needed + approval_program = deployment.create_params.approval_program + clear_program = deployment.create_params.clear_state_program + + if isinstance(approval_program, str): + compiled_approval = self._app_manager.compile_teal_template( + approval_program, + deployment.deploy_time_params, + deployment.metadata.__dict__, + ) + approval_program = compiled_approval.compiled_base64_to_bytes + + if isinstance(clear_program, str): + compiled_clear = self._app_manager.compile_teal_template( + clear_program, + deployment.deploy_time_params, + ) + clear_program = compiled_clear.compiled_base64_to_bytes + + # Get existing app metadata + apps = deployment.existing_deployments or self.get_creator_apps_by_name( + creator_address=deployment.create_params.sender, + ignore_cache=deployment.ignore_cache, + ) + + existing_app = apps.apps.get(deployment.metadata.name) + if not existing_app or existing_app.deleted: + return self._create_app( + deployment=deployment, + approval_program=approval_program, + clear_program=clear_program, + ) + + # Check for changes + existing_app_record = self._app_manager.get_by_id(existing_app.app_id) + + existing_approval = base64.b64encode(existing_app_record.approval_program).decode() + existing_clear = base64.b64encode(existing_app_record.clear_state_program).decode() + + new_approval = base64.b64encode(approval_program).decode() + new_clear = base64.b64encode(clear_program).decode() + + is_update = new_approval != existing_approval or new_clear != existing_clear + is_schema_break = ( + existing_app_record.local_ints + < (deployment.create_params.schema.get("local_ints", 0) if deployment.create_params.schema else 0) + or existing_app_record.global_ints + < (deployment.create_params.schema.get("global_ints", 0) if deployment.create_params.schema else 0) + or existing_app_record.local_byte_slices + < (deployment.create_params.schema.get("local_byte_slices", 0) if deployment.create_params.schema else 0) + or existing_app_record.global_byte_slices + < (deployment.create_params.schema.get("global_byte_slices", 0) if deployment.create_params.schema else 0) + ) + + if is_schema_break: + logger.warning( + f"Detected a breaking app schema change in app {existing_app.app_id}:", + extra={ + "from": { + "global_ints": existing_app_record.global_ints, + "global_byte_slices": existing_app_record.global_byte_slices, + "local_ints": existing_app_record.local_ints, + "local_byte_slices": existing_app_record.local_byte_slices, + }, + "to": deployment.create_params.schema, + }, + suppress_log=deployment.suppress_log, + ) + + return self._handle_schema_break( + deployment=deployment, + existing_app=existing_app, + approval_program=approval_program, + clear_program=clear_program, + ) + + if is_update: + return self._handle_update( + deployment=deployment, + existing_app=existing_app, + approval_program=approval_program, + clear_program=clear_program, + ) + + return AppDeployResult( + **existing_app.__dict__, + operation_performed=OperationPerformed.Nothing, + app_id=existing_app.app_id, + app_address=existing_app.app_address, + ) + + def _create_app( + self, + deployment: AppDeployParams, + approval_program: bytes, + clear_program: bytes, + ) -> AppDeployResult: + """Create a new application""" + + if isinstance(deployment.create_params, AppCreateMethodCall): + result = self._transaction_sender.app_create_method_call( + AppCreateMethodCall( + **{ + **deployment.create_params.__dict__, + "approval_program": approval_program, + "clear_state_program": clear_program, + } + ) + ) + else: + result = self._transaction_sender.app_create( + AppCreateParams( + **{ + **deployment.create_params.__dict__, + "approval_program": approval_program, + "clear_state_program": clear_program, + } + ) + ) + + app_metadata = AppMetaData( + app_id=result.app_id, + app_address=get_application_address(result.app_id), + **deployment.metadata.__dict__, + created_metadata=deployment.metadata, + created_round=result.confirmation.get("confirmed-round", 0) if isinstance(result.confirmation, dict) else 0, + updated_round=result.confirmation.get("confirmed-round", 0) if isinstance(result.confirmation, dict) else 0, + deleted=False, + ) + + self._update_app_lookup(deployment.create_params.sender, app_metadata) + + app_metadata_dict = app_metadata.__dict__ + app_metadata_dict["operation_performed"] = OperationPerformed.Create + app_metadata_dict["app_id"] = result.app_id + app_metadata_dict["app_address"] = get_application_address(result.app_id) + + return AppDeployResult( + **app_metadata_dict, + tx_id=result.tx_id, + tx_ids=result.tx_ids, + transaction=result.transaction, + transactions=result.transactions, + confirmation=result.confirmation, + confirmations=result.confirmations, + return_value=result.return_value, + ) + + def _handle_schema_break( + self, + deployment: AppDeployParams, + existing_app: AppMetaData, + approval_program: bytes, + clear_program: bytes, + ) -> AppDeployResult: + if deployment.on_schema_break in (OnSchemaBreak.Fail, "fail"): + raise ValueError( + "Schema break detected and onSchemaBreak=OnSchemaBreak.Fail, stopping deployment. " + "If you want to try deleting and recreating the app then " + "re-run with onSchemaBreak=OnSchemaBreak.ReplaceApp" + ) + + if deployment.on_schema_break in (OnSchemaBreak.AppendApp, "append"): + return self._create_app(deployment, approval_program, clear_program) + + if existing_app.deletable: + return self._replace_app(deployment, existing_app, approval_program, clear_program) + else: + raise ValueError("App is not deletable but onSchemaBreak=ReplaceApp, " "cannot delete and recreate app") + + def _handle_update( + self, + deployment: AppDeployParams, + existing_app: AppMetaData, + approval_program: bytes, + clear_program: bytes, + ) -> AppDeployResult: + if deployment.on_update in (OnUpdate.Fail, "fail"): + raise ValueError( + "Update detected and onUpdate=Fail, stopping deployment. " "Try a different onUpdate value to not fail." + ) + + if deployment.on_update in (OnUpdate.AppendApp, "append"): + return self._create_app(deployment, approval_program, clear_program) + + if deployment.on_update in (OnUpdate.UpdateApp, "update"): + if existing_app.updatable: + return self._update_app(deployment, existing_app, approval_program, clear_program) + else: + raise ValueError("App is not updatable but onUpdate=UpdateApp, cannot update app") + + if deployment.on_update in (OnUpdate.ReplaceApp, "replace"): + if existing_app.deletable: + return self._replace_app(deployment, existing_app, approval_program, clear_program) + else: + raise ValueError("App is not deletable but onUpdate=ReplaceApp, " "cannot delete and recreate app") + + raise ValueError(f"Unsupported onUpdate value: {deployment.on_update}") + + def _replace_app( + self, + deployment: AppDeployParams, + existing_app: AppMetaData, + approval_program: bytes, + clear_program: bytes, + ) -> AppDeployResult: + composer = self._transaction_sender.new_group() + + # Add create transaction + if isinstance(deployment.create_params, AppCreateMethodCall): + composer.add_app_create_method_call( + AppCreateMethodCall( + **{ + **deployment.create_params.__dict__, + "approval_program": approval_program, + "clear_state_program": clear_program, + } + ) + ) + else: + composer.add_app_create( + AppCreateParams( + **{ + **deployment.create_params.__dict__, + "approval_program": approval_program, + "clear_state_program": clear_program, + } + ) + ) + + # Add delete transaction + if isinstance(deployment.delete_params, AppDeleteMethodCall): + delete_call_params = AppDeleteMethodCall( + **{ + **deployment.delete_params.__dict__, + "app_id": existing_app.app_id, + } + ) + composer.add_app_delete_method_call(delete_call_params) + else: + delete_params = AppDeleteParams( + **{ + **deployment.delete_params.__dict__, + "app_id": existing_app.app_id, + } + ) + composer.add_app_delete(delete_params) + + result = composer.send() + + app_id = int(result.confirmations[0]["application-index"]) # type: ignore[call-overload] + app_metadata = AppMetaData( + app_id=app_id, + app_address=get_application_address(app_id), + **deployment.metadata.__dict__, + created_metadata=deployment.metadata, + created_round=result.confirmations[0]["confirmed-round"], # type: ignore[call-overload] + updated_round=result.confirmations[0]["confirmed-round"], # type: ignore[call-overload] + deleted=False, + ) + self._update_app_lookup(deployment.create_params.sender, app_metadata) + + app_metadata_dict = app_metadata.__dict__ + app_metadata_dict["operation_performed"] = OperationPerformed.Replace + app_metadata_dict["app_id"] = app_id + app_metadata_dict["app_address"] = get_application_address(app_id) + + # Extract return_value and delete_return_value from ABIResult + return_value = result.returns[0] if result.returns and isinstance(result.returns[0], ABIResult) else None + delete_return_value = ( + result.returns[-1] if len(result.returns) > 1 and isinstance(result.returns[-1], ABIResult) else None + ) + + return AppDeployResult( + **app_metadata_dict, + tx_id=result.tx_ids[0], + tx_ids=result.tx_ids, + transaction=result.transactions[0], + transactions=result.transactions, + confirmation=result.confirmations[0], + confirmations=result.confirmations, + return_value=return_value, + delete_return_value=delete_return_value, + delete_result=ConfirmedTransactionResult( + transaction=result.transactions[-1], + confirmation=result.confirmations[-1], + ), + ) + + def _update_app( + self, + deployment: AppDeployParams, + existing_app: AppMetaData, + approval_program: bytes, + clear_program: bytes, + ) -> AppDeployResult: + """Update an existing application""" + + if isinstance(deployment.update_params, AppUpdateMethodCall): + result = self._transaction_sender.app_update_method_call( + AppUpdateMethodCall( + **{ + **deployment.update_params.__dict__, + "app_id": existing_app.app_id, + "approval_program": approval_program, + "clear_state_program": clear_program, + } + ) + ) + else: + result = self._transaction_sender.app_update( + AppUpdateParams( + **{ + **deployment.update_params.__dict__, + "app_id": existing_app.app_id, + "approval_program": approval_program, + "clear_state_program": clear_program, + } + ) + ) + + app_metadata = AppMetaData( + app_id=existing_app.app_id, + app_address=existing_app.app_address, + created_metadata=existing_app.created_metadata, + created_round=existing_app.created_round, + updated_round=result.confirmation.get("confirmed-round", 0) if isinstance(result.confirmation, dict) else 0, + **deployment.metadata.__dict__, + deleted=False, + ) + + self._update_app_lookup(deployment.create_params.sender, app_metadata) + + return AppDeployResult( + **app_metadata.__dict__, + operation_performed=OperationPerformed.Update, + transaction=result.transaction, + transactions=result.transactions, + confirmation=result.confirmation, + confirmations=result.confirmations, + return_value=result.return_value, + ) + + def _update_app_lookup(self, sender: str, app_metadata: AppMetaData) -> None: + """Update the app lookup cache""" + + lookup = self._app_lookups.get(sender) + if not lookup: + self._app_lookups[sender] = AppLookup( + creator=sender, + apps={app_metadata.name: app_metadata}, + ) + else: + lookup.apps[app_metadata.name] = app_metadata + + def get_creator_apps_by_name(self, *, creator_address: str, ignore_cache: bool = False) -> AppLookup: + """Get apps created by an account""" + + if not ignore_cache and creator_address in self._app_lookups: + return self._app_lookups[creator_address] + + if not self._indexer: + raise ValueError( + "Didn't receive an indexer client when this AppManager was created, " + "but received a call to get_creator_apps" + ) + + app_lookup: dict[str, AppMetaData] = {} + + # Get all apps created by account + created_apps = self._indexer.search_applications(creator=creator_address) + + for app in created_apps["applications"]: + app_id = app["id"] + + # Get creation transaction + creation_txns = self._indexer.search_transactions( + application_id=app_id, + min_round=app["created-at-round"], + address=creator_address, + address_role="sender", + note_prefix=base64.b64encode(APP_DEPLOY_NOTE_DAPP.encode()), + limit=1, + ) + + if not creation_txns["transactions"]: + continue + + creation_txn = creation_txns["transactions"][0] + + try: + note = base64.b64decode(creation_txn["note"]).decode() + if not note.startswith(f"{APP_DEPLOY_NOTE_DAPP}:j"): + continue + + metadata = json.loads(note[len(APP_DEPLOY_NOTE_DAPP) + 2 :]) + + if metadata.get("name"): + app_lookup[metadata["name"]] = AppMetaData( + app_id=app_id, + app_address=get_application_address(app_id), + created_metadata=metadata, + created_round=creation_txn["confirmed-round"], + **metadata, + updated_round=creation_txn["confirmed-round"], + deleted=app.get("deleted", False), + ) + except Exception as e: + logger.warning( + f"Error processing app {app_id} for creator {creator_address}: {e}", + ) + continue + + lookup = AppLookup(creator=creator_address, apps=app_lookup) + self._app_lookups[creator_address] = lookup + return lookup diff --git a/src/algokit_utils/applications/app_factory.py b/src/algokit_utils/applications/app_factory.py new file mode 100644 index 00000000..2c8c3a94 --- /dev/null +++ b/src/algokit_utils/applications/app_factory.py @@ -0,0 +1,690 @@ +import base64 +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, TypeGuard, TypeVar + +import algosdk +from algosdk import transaction +from algosdk.abi import Method +from algosdk.atomic_transaction_composer import ABIResult, TransactionSigner +from algosdk.source_map import SourceMap +from algosdk.transaction import OnComplete, Transaction + +from algokit_utils._legacy_v2.application_specification import ApplicationSpecification +from algokit_utils._legacy_v2.deploy import AppDeployMetaData, AppLookup, OnSchemaBreak, OnUpdate, OperationPerformed +from algokit_utils.applications.app_client import ( + AppClient, + AppClientBareCallParams, + AppClientCompilationParams, + AppClientCompilationResult, + AppClientMethodCallParams, + AppClientParams, + AppSourceMaps, + ExposedLogicErrorDetails, +) +from algokit_utils.applications.app_deployer import ( + AppDeployParams, + ConfirmedTransactionResult, + DeployAppDeleteParams, + DeployAppUpdateParams, +) +from algokit_utils.applications.app_manager import TealTemplateParams +from algokit_utils.applications.utils import ( + get_abi_decoded_value, + get_abi_tuple_from_abi_struct, + get_arc56_method, + get_arc56_return_value, +) +from algokit_utils.models.abi import ABIStruct, ABIValue +from algokit_utils.models.application import ( + DELETABLE_TEMPLATE_NAME, + UPDATABLE_TEMPLATE_NAME, + Arc56Contract, + Arc56Method, + CompiledTeal, + MethodArg, +) +from algokit_utils.models.transaction import SendParams +from algokit_utils.protocols.application import AlgorandClientProtocol +from algokit_utils.transactions.models import TransactionWrapper +from algokit_utils.transactions.transaction_composer import ( + AppCreateMethodCall, + AppCreateParams, + AppDeleteMethodCall, + AppUpdateMethodCall, + BuiltTransactions, +) +from algokit_utils.transactions.transaction_sender import SendAppCreateTransactionResult, SendAppTransactionResult + +T = TypeVar("T") + + +@dataclass(kw_only=True, frozen=True) +class AppFactoryParams: + algorand: AlgorandClientProtocol + app_spec: Arc56Contract | ApplicationSpecification | str + app_name: str | None = None + default_sender: str | bytes | None = None + default_signer: TransactionSigner | None = None + version: str | None = None + updatable: bool | None = None + deletable: bool | None = None + deploy_time_params: TealTemplateParams | None = None + + +@dataclass(kw_only=True, frozen=True) +class AppFactoryCreateParams(AppClientBareCallParams, AppClientCompilationParams): + on_complete: transaction.OnComplete | None = None + schema: dict[str, int] | None = None + extra_program_pages: int | None = None + + +@dataclass(kw_only=True, frozen=True) +class AppFactoryCreateWithSendParams(AppFactoryCreateParams, SendParams): + pass + + +@dataclass(kw_only=True, frozen=True) +class AppFactoryCreateMethodCallParams(AppClientMethodCallParams, AppClientCompilationParams): + on_complete: transaction.OnComplete | None = None + schema: dict[str, int] | None = None + extra_program_pages: int | None = None + + +@dataclass(kw_only=True, frozen=True) +class AppFactoryCreateMethodCallWithSendParams(AppFactoryCreateMethodCallParams, SendParams): + pass + + +@dataclass(frozen=True, kw_only=True) +class AppFactoryCreateResult(SendAppTransactionResult): + """Result from creating an application via AppFactory""" + + app_id: int + """The ID of the created application""" + app_address: str + """The address of the created application""" + compiled_approval: CompiledTeal | None = None + """The compiled approval program if source was provided""" + compiled_clear: CompiledTeal | None = None + """The compiled clear program if source was provided""" + + +@dataclass(kw_only=True, frozen=True) +class AppFactoryDeployResult: + """Represents the result object from app deployment""" + + app_address: str + app_id: int + approval_program: bytes # Uint8Array + clear_state_program: bytes # Uint8Array + compiled_approval: dict # Contains teal, compiled, compiledHash, compiledBase64ToBytes, sourceMap + compiled_clear: dict # Contains teal, compiled, compiledHash, compiledBase64ToBytes, sourceMap + confirmation: algosdk.v2client.algod.AlgodResponseType + confirmations: list[algosdk.v2client.algod.AlgodResponseType] | None = None + created_metadata: dict # {name: str, version: str, updatable: bool, deletable: bool} + created_round: int + deletable: bool + deleted: bool + delete_return_value: ABIValue | ABIStruct | None = None + delete_result: ConfirmedTransactionResult | None = None + group_id: str | None = None + name: str + operation_performed: OperationPerformed + return_value: ABIValue | ABIStruct | None = None + returns: list[Any] | None = None + transaction: TransactionWrapper + transactions: list[TransactionWrapper] + tx_id: str + tx_ids: list[str] + updatable: bool + updated_round: int + version: str + + +class _AppFactoryBareParamsAccessor: + def __init__(self, factory: "AppFactory") -> None: + self._factory = factory + self._algorand = factory._algorand + + def create(self, params: AppFactoryCreateParams | None = None) -> AppCreateParams: + create_args = {} + if params: + create_args = {**params.__dict__.copy()} + del create_args["schema"] + del create_args["sender"] + del create_args["on_complete"] + del create_args["deploy_time_params"] + del create_args["updatable"] + del create_args["deletable"] + compiled = self._factory.compile(params) + create_args["approval_program"] = compiled.approval_program + create_args["clear_state_program"] = compiled.clear_state_program + + return AppCreateParams( + **create_args, + schema=(params.schema if params else None) + or { + "global_bytes": self._factory._app_spec.state.schemas["global"]["bytes"], + "global_ints": self._factory._app_spec.state.schemas["global"]["ints"], + "local_bytes": self._factory._app_spec.state.schemas["local"]["bytes"], + "local_ints": self._factory._app_spec.state.schemas["local"]["ints"], + }, + sender=self._factory._get_sender(params.sender if params else None), + on_complete=(params.on_complete if params else None) or OnComplete.NoOpOC, + ) + + def deploy_update(self, params: AppClientBareCallParams | None = None) -> dict[str, Any]: + return { + **(params.__dict__ if params else {}), + "sender": self._factory._get_sender(params.sender if params else None), + "on_complete": OnComplete.UpdateApplicationOC, + } + + def deploy_delete(self, params: AppClientBareCallParams | None = None) -> dict[str, Any]: + return { + **(params.__dict__ if params else {}), + "sender": self._factory._get_sender(params.sender if params else None), + "on_complete": OnComplete.DeleteApplicationOC, + } + + +class _AppFactoryParamsAccessor: + def __init__(self, factory: "AppFactory") -> None: + self._factory = factory + self._bare = _AppFactoryBareParamsAccessor(factory) + + @property + def bare(self) -> _AppFactoryBareParamsAccessor: + return self._bare + + def create(self, params: AppFactoryCreateMethodCallParams) -> AppCreateMethodCall: + compiled = self._factory.compile(params) + params_dict = params.__dict__ + params_dict["schema"] = params.schema or { + "global_bytes": self._factory._app_spec.state.schemas["global"]["bytes"], + "global_ints": self._factory._app_spec.state.schemas["global"]["ints"], + "local_bytes": self._factory._app_spec.state.schemas["local"]["bytes"], + "local_ints": self._factory._app_spec.state.schemas["local"]["ints"], + } + params_dict["sender"] = self._factory._get_sender(params.sender) + params_dict["method"] = get_arc56_method(params.method, self._factory._app_spec) + params_dict["args"] = self._factory._get_create_abi_args_with_default_values(params.method, params.args) + params_dict["on_complete"] = params.on_complete or OnComplete.NoOpOC + del params_dict["deploy_time_params"] + del params_dict["updatable"] + del params_dict["deletable"] + return AppCreateMethodCall( + **params_dict, + app_id=0, + approval_program=compiled.approval_program, + clear_state_program=compiled.clear_state_program, + ) + + def deploy_update(self, params: AppClientMethodCallParams) -> AppUpdateMethodCall: + params_dict = params.__dict__.copy() + params_dict["sender"] = self._factory._get_sender(params.sender) + params_dict["method"] = get_arc56_method(params.method, self._factory._app_spec) + params_dict["args"] = self._factory._get_create_abi_args_with_default_values(params.method, params.args) + params_dict["on_complete"] = OnComplete.UpdateApplicationOC + return AppUpdateMethodCall(**params_dict, app_id=0, approval_program="", clear_state_program="") + + def deploy_delete(self, params: AppClientMethodCallParams) -> AppDeleteMethodCall: + params_dict = params.__dict__.copy() + params_dict["sender"] = self._factory._get_sender(params.sender) + params_dict["method"] = get_arc56_method(params.method, self._factory._app_spec) + params_dict["args"] = self._factory._get_create_abi_args_with_default_values(params.method, params.args) + params_dict["on_complete"] = OnComplete.DeleteApplicationOC + return AppDeleteMethodCall(**params_dict, app_id=0) + + +class _AppFactoryBareCreateTransactionAccessor: + def __init__(self, factory: "AppFactory") -> None: + self._factory = factory + + def create(self, params: AppFactoryCreateParams | None = None) -> Transaction: + return self._factory._algorand.create_transaction.app_create(self._factory.params.bare.create(params)) + + +class _AppFactoryCreateTransactionAccessor: + def __init__(self, factory: "AppFactory") -> None: + self._factory = factory + self._bare = _AppFactoryBareCreateTransactionAccessor(factory) + + @property + def bare(self) -> _AppFactoryBareCreateTransactionAccessor: + return self._bare + + def create(self, params: AppFactoryCreateMethodCallParams) -> BuiltTransactions: + return self._factory._algorand.create_transaction.app_create_method_call(self._factory.params.create(params)) + + +class _AppFactoryBareSendAccessor: + def __init__(self, factory: "AppFactory") -> None: + self._factory = factory + self._algorand = factory._algorand + + def create(self, params: AppFactoryCreateWithSendParams | None = None) -> tuple[AppClient, AppFactoryCreateResult]: + updatable = params.updatable if params and params.updatable is not None else self._factory._updatable + deletable = params.deletable if params and params.deletable is not None else self._factory._deletable + deploy_time_params = ( + params.deploy_time_params + if params and params.deploy_time_params is not None + else self._factory._deploy_time_params + ) + + compiled = self._factory.compile( + AppClientCompilationParams( + deploy_time_params=deploy_time_params, + updatable=updatable, + deletable=deletable, + ) + ) + + create_args = {} + if params: + create_args = {**params.__dict__} + del create_args["max_rounds_to_wait"] + del create_args["suppress_log"] + del create_args["populate_app_call_resources"] + + create_args["updatable"] = updatable + create_args["deletable"] = deletable + create_args["deploy_time_params"] = deploy_time_params + + result = self._factory._handle_call_errors( + lambda: self._algorand.send.app_create( + self._factory.params.bare.create(AppFactoryCreateParams(**create_args)) + ) + ).__dict__ + + result["compiled_approval"] = compiled.compiled_approval + result["compiled_clear"] = compiled.compiled_clear + + return ( + self._factory.get_app_client_by_id( + app_id=result["app_id"], + ), + AppFactoryCreateResult(**result), + ) + + +class _AppFactorySendAccessor: + def __init__(self, factory: "AppFactory") -> None: + self._factory = factory + self._algorand = factory._algorand + self._bare = _AppFactoryBareSendAccessor(factory) + + @property + def bare(self) -> _AppFactoryBareSendAccessor: + return self._bare + + def create(self, params: AppFactoryCreateMethodCallParams) -> tuple[AppClient, SendAppCreateTransactionResult]: + updatable = params.updatable if params.updatable is not None else self._factory._updatable + deletable = params.deletable if params.deletable is not None else self._factory._deletable + deploy_time_params = ( + params.deploy_time_params if params.deploy_time_params is not None else self._factory._deploy_time_params + ) + + compiled = self._factory.compile( + AppClientCompilationParams( + deploy_time_params=deploy_time_params, + updatable=updatable, + deletable=deletable, + ) + ) + + create_params_dict = params.__dict__.copy() + create_params_dict["updatable"] = updatable + create_params_dict["deletable"] = deletable + create_params_dict["deploy_time_params"] = deploy_time_params + result = self._factory._handle_call_errors( + lambda: self._algorand.send.app_create_method_call( + self._factory.params.create(AppFactoryCreateMethodCallParams(**create_params_dict)) + ) + ) + + return ( + self._factory.get_app_client_by_id( + app_id=result.app_id, + ), + SendAppCreateTransactionResult( + **{ + **result.__dict__, + **( + {"compiled_approval": compiled.compiled_approval, "compiled_clear": compiled.compiled_clear} + if compiled + else {} + ), + } + ), + ) + + +class AppFactory: + def __init__(self, params: AppFactoryParams) -> None: + self._app_spec = AppClient.normalise_app_spec(params.app_spec) + self._app_name = params.app_name or self._app_spec.name + self._algorand = params.algorand + self._version = params.version or "1.0" + self._default_sender = params.default_sender + self._default_signer = params.default_signer + self._deploy_time_params = params.deploy_time_params + self._updatable = params.updatable + self._deletable = params.deletable + self._approval_source_map: SourceMap | None = None + self._clear_source_map: SourceMap | None = None + self._params_accessor = _AppFactoryParamsAccessor(self) + self._send_accessor = _AppFactorySendAccessor(self) + self._create_transaction_accessor = _AppFactoryCreateTransactionAccessor(self) + + @property + def app_name(self) -> str: + return self._app_name + + @property + def app_spec(self) -> Arc56Contract: + return self._app_spec + + @property + def algorand(self) -> AlgorandClientProtocol: + return self._algorand + + @property + def params(self) -> _AppFactoryParamsAccessor: + return self._params_accessor + + @property + def send(self) -> _AppFactorySendAccessor: + return self._send_accessor + + @property + def create_transaction(self) -> _AppFactoryCreateTransactionAccessor: + return self._create_transaction_accessor + + def deploy( # noqa: PLR0913 + self, + *, + deploy_time_params: TealTemplateParams | None = None, + on_update: OnUpdate = OnUpdate.Fail, + on_schema_break: OnSchemaBreak = OnSchemaBreak.Fail, + create_params: AppClientMethodCallParams | AppClientBareCallParams | None = None, + update_params: AppClientMethodCallParams | AppClientBareCallParams | None = None, + delete_params: AppClientMethodCallParams | AppClientBareCallParams | None = None, + existing_deployments: AppLookup | None = None, + ignore_cache: bool = False, + updatable: bool | None = None, + deletable: bool | None = None, + app_name: str | None = None, + max_rounds_to_wait: int | None = None, # noqa: ARG002 TODO: revisit + suppress_log: bool = False, # noqa: ARG002 TODO: revisit + populate_app_call_resources: bool = False, # noqa: ARG002 TODO: revisit + ) -> tuple[AppClient, AppFactoryDeployResult]: + updatable = ( + updatable if updatable is not None else self._updatable or self._get_deploy_time_control("updatable") + ) + deletable = ( + deletable if deletable is not None else self._deletable or self._get_deploy_time_control("deletable") + ) + deploy_time_params = deploy_time_params if deploy_time_params is not None else self._deploy_time_params + + compiled = self.compile( + AppClientCompilationParams( + deploy_time_params=deploy_time_params, + updatable=updatable, + deletable=deletable, + ) + ) + + def _is_method_call_params( + params: AppClientMethodCallParams | AppClientBareCallParams | None, + ) -> TypeGuard[AppClientMethodCallParams]: + return params is not None and hasattr(params, "method") + + update_args: DeployAppUpdateParams | AppUpdateMethodCall + if _is_method_call_params(update_params): + update_args = self.params.deploy_update(update_params) + else: + update_args = DeployAppUpdateParams( + **self.params.bare.deploy_update( + update_params if isinstance(update_params, AppClientBareCallParams) else None + ) + ) + + delete_args: DeployAppDeleteParams | AppDeleteMethodCall + if _is_method_call_params(delete_params): + delete_args = self.params.deploy_delete(delete_params) + else: + delete_args = DeployAppDeleteParams( + **self.params.bare.deploy_delete( + delete_params if isinstance(delete_params, AppClientBareCallParams) else None + ) + ) + + app_deploy_params = AppDeployParams( + deploy_time_params=deploy_time_params, + on_schema_break=on_schema_break, + on_update=on_update, + existing_deployments=existing_deployments, + ignore_cache=ignore_cache, + create_params=( + self.params.create( + AppFactoryCreateMethodCallParams( + **create_params.__dict__, + updatable=updatable, + deletable=deletable, + deploy_time_params=deploy_time_params, + ) + ) + if create_params and hasattr(create_params, "method") + else self.params.bare.create( + AppFactoryCreateParams( + **create_params.__dict__ if create_params else {}, + updatable=updatable, + deletable=deletable, + deploy_time_params=deploy_time_params, + ) + ) + ), + update_params=update_args, + delete_params=delete_args, + metadata=AppDeployMetaData( + name=app_name or self._app_name, + version=self._version, + updatable=updatable, + deletable=deletable, + ), + ) + deploy_result = self._algorand.app_deployer.deploy(app_deploy_params) + + app_client = self.get_app_client_by_id( + app_id=deploy_result.app_id or 0, + app_name=app_name, + default_sender=self._default_sender, + default_signer=self._default_signer, + ) + + result = {**deploy_result.__dict__, **(compiled.__dict__ if compiled else {})} + + if "return_value" in result: + if result["operation_performed"] == OperationPerformed.Update: + if update_params and isinstance(update_params, AppClientMethodCallParams): + result["return_value"] = get_arc56_return_value( + result["return_value"], + get_arc56_method(update_params.method, self._app_spec), + self._app_spec.structs, + ) + elif create_params and isinstance(create_params, AppClientMethodCallParams): + result["return_value"] = get_arc56_return_value( + result["return_value"], + get_arc56_method(create_params.method, self._app_spec), + self._app_spec.structs, + ) + + if "delete_return_value" in result and delete_params and isinstance(delete_params, AppClientMethodCallParams): + result["delete_return_value"] = get_arc56_return_value( + result["delete_return_value"], + get_arc56_method(delete_params.method, self._app_spec), + self._app_spec.structs, + ) + + return app_client, AppFactoryDeployResult(**result) + + def get_app_client_by_id( + self, + app_id: int, + app_name: str | None = None, + default_sender: str | bytes | None = None, # Address can be string or bytes + default_signer: TransactionSigner | None = None, + approval_source_map: SourceMap | None = None, + clear_source_map: SourceMap | None = None, + ) -> AppClient: + return AppClient( + AppClientParams( + app_id=app_id, + algorand=self._algorand, + app_spec=self._app_spec, + app_name=app_name or self._app_name, + default_sender=default_sender or self._default_sender, + default_signer=default_signer or self._default_signer, + approval_source_map=approval_source_map or self._approval_source_map, + clear_source_map=clear_source_map or self._clear_source_map, + ) + ) + + def expose_logic_error(self, e: Exception, is_clear_state_program: bool = False) -> Exception: # noqa: FBT002 FBT001 TODO: revisit + return AppClient.expose_logic_error_static( + e, + self._app_spec, + ExposedLogicErrorDetails( + is_clear_state_program=is_clear_state_program, + approval_source_map=self._approval_source_map, + clear_source_map=self._clear_source_map, + program=None, + approval_source_info=( + self._app_spec.source_info.get("approval") + if self._app_spec.source_info and hasattr(self._app_spec, "source_info") + else None + ), + clear_source_info=( + self._app_spec.source_info.get("clear") + if self._app_spec.source_info and hasattr(self._app_spec, "source_info") + else None + ), + ), + ) + + def export_source_maps(self) -> AppSourceMaps: + if not self._approval_source_map or not self._clear_source_map: + raise ValueError( + "Unable to export source maps; they haven't been loaded into this client - " + "you need to call create, update, or deploy first" + ) + return AppSourceMaps( + approval_source_map=self._approval_source_map, + clear_source_map=self._clear_source_map, + ) + + def import_source_maps(self, source_maps: AppSourceMaps) -> None: + self._approval_source_map = source_maps.approval_source_map + self._clear_source_map = source_maps.clear_source_map + + def compile(self, compilation: AppClientCompilationParams | None = None) -> AppClientCompilationResult: + result = AppClient.compile( + self._app_spec, + self._algorand.app, + deploy_time_params=compilation.deploy_time_params if compilation else None, + updatable=compilation.updatable if compilation else None, + deletable=compilation.deletable if compilation else None, + ) + + if result.compiled_approval: + self._approval_source_map = result.compiled_approval.source_map + if result.compiled_clear: + self._clear_source_map = result.compiled_clear.source_map + + return result + + def _get_deploy_time_control(self, control: str) -> bool | None: + approval = ( + self._app_spec.source["approval"] if self._app_spec.source and "approval" in self._app_spec.source else None + ) + + template_name = UPDATABLE_TEMPLATE_NAME if control == "updatable" else DELETABLE_TEMPLATE_NAME + if not approval or template_name not in approval: + return None + + on_complete = "UpdateApplication" if control == "updatable" else "DeleteApplication" + return on_complete in self._app_spec.bare_actions.get("call", []) or any( + on_complete in m.actions.call for m in self._app_spec.methods if m.actions and m.actions.call + ) + + def _get_sender(self, sender: str | bytes | None) -> str: + if not sender and not self._default_sender: + raise Exception( + f"No sender provided and no default sender present in app client for call to app {self._app_name}" + ) + return str(sender or self._default_sender) + + def _handle_call_errors(self, call: Callable[[], T]) -> T: + try: + return call() + except Exception as e: + raise self.expose_logic_error(e) from None + + def _parse_method_call_return(self, result: SendAppTransactionResult, method: Method) -> SendAppTransactionResult: + return SendAppTransactionResult( + **{ + **result.__dict__, + "return_value": get_arc56_return_value(result.return_value, method, self._app_spec.structs) + if isinstance(result.return_value, ABIResult) + else None, + } + ) + + def _get_create_abi_args_with_default_values( + self, + method_name_or_signature: str | Arc56Method, + args: list[Any] | None, + ) -> list[Any]: + method = ( + get_arc56_method(method_name_or_signature, self._app_spec) + if isinstance(method_name_or_signature, str) + else method_name_or_signature + ) + result = [] + + def _has_struct(arg: Any) -> TypeGuard[MethodArg]: # noqa: ANN401 + return hasattr(arg, "struct") + + for i, method_arg in enumerate(method.args): + arg = method_arg + arg_value = args[i] if args and i < len(args) else None + + if arg_value is not None: + if _has_struct(arg) and arg.struct and isinstance(arg_value, dict): + arg_value = get_abi_tuple_from_abi_struct( + arg_value, + self._app_spec.structs[arg.struct], + self._app_spec.structs, + ) + result.append(arg_value) + continue + + default_value = getattr(arg, "default_value", None) + if default_value: + if default_value.source == "literal": + value_raw = base64.b64decode(default_value.data) + value_type = default_value.type or str(arg.type) + result.append(get_abi_decoded_value(value_raw, value_type, self._app_spec.structs)) + else: + raise ValueError( + f"Can't provide default value for {default_value.source} for a contract creation call" + ) + else: + raise ValueError( + f"No value provided for required argument " + f"{arg.name or f'arg{i+1}'} in call to method {method.name}" + ) + + return result diff --git a/src/algokit_utils/applications/app_manager.py b/src/algokit_utils/applications/app_manager.py index 307d5e0f..e282ede7 100644 --- a/src/algokit_utils/applications/app_manager.py +++ b/src/algokit_utils/applications/app_manager.py @@ -2,33 +2,44 @@ from collections.abc import Mapping from dataclasses import dataclass from enum import IntEnum -from typing import Any, TypeAlias +from typing import Any, TypeAlias, cast import algosdk import algosdk.atomic_transaction_composer import algosdk.box_reference -from algosdk.atomic_transaction_composer import AccountTransactionSigner +from algosdk.atomic_transaction_composer import ABIResult, AccountTransactionSigner +from algosdk.box_reference import BoxReference as AlgosdkBoxReference from algosdk.logic import get_application_address +from algosdk.source_map import SourceMap from algosdk.v2client import algod -from algokit_utils.models.abi import ABIValue -from algokit_utils.models.application import DELETABLE_TEMPLATE_NAME, UPDATABLE_TEMPLATE_NAME +from algokit_utils.models.abi import ABIType, ABIValue +from algokit_utils.models.application import ( + DELETABLE_TEMPLATE_NAME, + UPDATABLE_TEMPLATE_NAME, + AppInformation, + AppState, + CompiledTeal, +) -@dataclass(frozen=True) +@dataclass(kw_only=True, frozen=True) class BoxName: name: str name_raw: bytes name_base64: str -@dataclass(frozen=True) -class AppState: - key_raw: bytes - key_base64: str - value_raw: bytes | None - value_base64: str | None - value: str | int +@dataclass(kw_only=True, frozen=True) +class BoxValue: + name: BoxName + value: bytes + + +@dataclass(kw_only=True, frozen=True) +class BoxABIValue: + name: BoxName + value: ABIValue class DataTypeFlag(IntEnum): @@ -39,31 +50,17 @@ class DataTypeFlag(IntEnum): TealTemplateParams: TypeAlias = Mapping[str, str | int | bytes] | dict[str, str | int | bytes] -@dataclass(frozen=True) -class AppInformation: - app_id: int - app_address: str - approval_program: bytes - clear_state_program: bytes - creator: str - global_state: dict[str, AppState] - local_ints: int - local_byte_slices: int - global_ints: int - global_byte_slices: int - extra_program_pages: int | None +BoxIdentifier: TypeAlias = str | bytes | AccountTransactionSigner -@dataclass(frozen=True) -class CompiledTeal: - teal: str - compiled: bytes - compiled_hash: str - compiled_base64_to_bytes: bytes - source_map: dict | None +class BoxReference(AlgosdkBoxReference): + def __init__(self, app_id: int, name: bytes): + super().__init__(app_index=app_id, name=name) - -BoxIdentifier = str | bytes | AccountTransactionSigner + def __eq__(self, other: object) -> bool: + if isinstance(other, (BoxReference | AlgosdkBoxReference)): + return self.app_index == other.app_index and self.name == other.name + return False def _is_valid_token_character(char: str) -> bool: @@ -134,7 +131,7 @@ def _find_unquoted_string(line: str, token: str, start: int = 0, end: int = -1) def _replace_template_variable(program_lines: list[str], template_variable: str, value: str) -> tuple[list[str], int]: result: list[str] = [] match_count = 0 - token = f"TMPL_{template_variable}" + token = f"TMPL_{template_variable}" if not template_variable.startswith("TMPL_") else template_variable token_idx_offset = len(value) - len(token) for line in program_lines: comment_idx = _find_unquoted_string(line, "//") @@ -173,7 +170,7 @@ def compile_teal(self, teal_code: str) -> CompiledTeal: compiled=compiled["result"], compiled_hash=compiled["hash"], compiled_base64_to_bytes=base64.b64decode(compiled["result"]), - source_map=compiled.get("sourcemap"), + source_map=SourceMap(compiled.get("sourcemap", {})), ) self._compilation_results[teal_code] = result return result @@ -182,7 +179,7 @@ def compile_teal_template( self, teal_template_code: str, template_params: TealTemplateParams | None = None, - deployment_metadata: dict[str, bool] | None = None, + deployment_metadata: Mapping[str, bool] | None = None, ) -> CompiledTeal: teal_code = AppManager.strip_teal_comments(teal_template_code) teal_code = AppManager.replace_template_variables(teal_code, template_params or {}) @@ -237,41 +234,51 @@ def get_box_names(self, app_id: int) -> list[BoxName]: ] def get_box_value(self, app_id: int, box_name: BoxIdentifier) -> bytes: - name = b"" - if isinstance(box_name, str): - name = box_name.encode("utf-8") - elif isinstance(box_name, bytes): - name = box_name - elif isinstance(box_name, AccountTransactionSigner): - name = algosdk.encoding.decode_address(algosdk.account.address_from_private_key(box_name.private_key)) - else: - raise ValueError(f"Invalid box identifier type: {type(box_name)}") - + name = AppManager.get_box_reference(box_name)[1] box_result = self._algod.application_box_by_name(app_id, name) assert isinstance(box_result, dict) - return base64.b64decode(box_result["value"]) + return bytes(box_result["value"], "utf-8") def get_box_values(self, app_id: int, box_names: list[BoxIdentifier]) -> list[bytes]: return [self.get_box_value(app_id, box_name) for box_name in box_names] - def get_box_value_from_abi_type( - self, app_id: int, box_name: BoxIdentifier, abi_type: algosdk.abi.ABIType - ) -> ABIValue: + def get_box_value_from_abi_type(self, app_id: int, box_name: BoxIdentifier, abi_type: ABIType) -> ABIValue: value = self.get_box_value(app_id, box_name) try: - return abi_type.decode(value) # type: ignore[no-any-return] + parse_to_tuple = isinstance(abi_type, algosdk.abi.TupleType) + decoded_value = abi_type.decode(base64.b64decode(value)) + return tuple(decoded_value) if parse_to_tuple else decoded_value except Exception as e: raise ValueError(f"Failed to decode box value {value.decode('utf-8')} with ABI type {abi_type}") from e def get_box_values_from_abi_type( - self, app_id: int, box_names: list[BoxIdentifier], abi_type: algosdk.abi.ABIType + self, app_id: int, box_names: list[BoxIdentifier], abi_type: ABIType ) -> list[ABIValue]: return [self.get_box_value_from_abi_type(app_id, box_name, abi_type) for box_name in box_names] + @staticmethod + def get_box_reference(box_id: BoxIdentifier | BoxReference) -> tuple[int, bytes]: + if isinstance(box_id, (BoxReference | AlgosdkBoxReference)): + return box_id.app_index, box_id.name + + name = b"" + if isinstance(box_id, str): + name = box_id.encode("utf-8") + elif isinstance(box_id, bytes): + name = box_id + elif isinstance(box_id, AccountTransactionSigner): + name = cast( + bytes, algosdk.encoding.decode_address(algosdk.account.address_from_private_key(box_id.private_key)) + ) + else: + raise ValueError(f"Invalid box identifier type: {type(box_id)}") + + return 0, name + @staticmethod def get_abi_return( confirmation: algosdk.v2client.algod.AlgodResponseType, method: algosdk.abi.Method | None = None - ) -> ABIValue | None: + ) -> ABIResult | None: """Get the ABI return value from a transaction confirmation.""" if not method: return None @@ -287,7 +294,7 @@ def get_abi_return( if not abi_result: return None - return abi_result.return_value # type: ignore[no-any-return] + return abi_result @staticmethod def decode_app_state(state: list[dict[str, Any]]) -> dict[str, AppState]: @@ -346,7 +353,7 @@ def replace_template_variables(program: str, template_values: TealTemplateParams return "\n".join(program_lines) @staticmethod - def replace_teal_template_deploy_time_control_params(teal_template_code: str, params: dict[str, bool]) -> str: + def replace_teal_template_deploy_time_control_params(teal_template_code: str, params: Mapping[str, bool]) -> str: if params.get("updatable") is not None: if UPDATABLE_TEMPLATE_NAME not in teal_template_code: raise ValueError( diff --git a/src/algokit_utils/applications/utils.py b/src/algokit_utils/applications/utils.py new file mode 100644 index 00000000..05bc4650 --- /dev/null +++ b/src/algokit_utils/applications/utils.py @@ -0,0 +1,428 @@ +import base64 +from typing import Any, Literal, TypeVar + +from algosdk.abi import Method as AlgorandABIMethod +from algosdk.abi import TupleType +from algosdk.atomic_transaction_composer import ABIResult + +from algokit_utils._legacy_v2.application_specification import ( + ApplicationSpecification, + AppSpecStateDict, + DefaultArgumentDict, + MethodConfigDict, + MethodHints, +) +from algokit_utils.models.abi import ABIStruct, ABIType, ABIValue +from algokit_utils.models.application import ( + ABIArgumentType, + ABITypeAlias, + Arc56Contract, + Arc56ContractState, + Arc56Method, + CallConfig, + DefaultValue, + Method, + MethodActions, + MethodArg, + MethodReturns, + OnCompleteAction, + StorageKey, + StructField, + StructName, +) + +T = TypeVar("T", bound=ABIValue | bytes | ABIStruct | None) + + +def get_arc56_method(method_name_or_signature: str, app_spec: Arc56Contract) -> Arc56Method: + if "(" not in method_name_or_signature: + # Filter by method name + methods = [m for m in app_spec.methods if m.name == method_name_or_signature] + if not methods: + raise ValueError(f"Unable to find method {method_name_or_signature} in {app_spec.name} app.") + if len(methods) > 1: + signatures = [AlgorandABIMethod.undictify(m.__dict__).get_signature() for m in app_spec.methods] + raise ValueError( + f"Received a call to method {method_name_or_signature} in contract {app_spec.name}, " + f"but this resolved to multiple methods; please pass in an ABI signature instead: " + f"{', '.join(signatures)}" + ) + method = methods[0] + else: + # Find by signature + method = None + for m in app_spec.methods: + abi_method = AlgorandABIMethod.undictify(m.to_dict()) + if abi_method.get_signature() == method_name_or_signature: + method = m + break + + if method is None: + raise ValueError(f"Unable to find method {method_name_or_signature} in {app_spec.name} app.") + + return Arc56Method(method) + + +def get_arc56_return_value( + return_value: ABIResult | None, + method: Method | AlgorandABIMethod, + structs: dict[str, list[StructField]], +) -> ABIValue | ABIStruct | None: + """Checks for decode errors on the return value and maps it to the specified type. + + Args: + return_value: The smart contract response + method: The method that was called + structs: The struct fields from the app spec + + Returns: + The smart contract response with an updated return value + + Raises: + ValueError: If there is a decode error + """ + + # Get method returns info + if isinstance(method, AlgorandABIMethod): + type_str = method.returns.type + struct = None # AlgorandABIMethod doesn't have struct info + else: + type_str = method.returns.type + struct = method.returns.struct + + # Handle void/undefined returns + if type_str == "void" or return_value is None: + return None + + # Handle decode errors + if return_value.decode_error: + raise ValueError(return_value.decode_error) + + # Get raw return value + raw_value = return_value.raw_value + + # Handle AVM types + if type_str == "AVMBytes": + return raw_value + if type_str == "AVMString" and raw_value: + return raw_value.decode("utf-8") + if type_str == "AVMUint64" and raw_value: + return ABIType.from_string("uint64").decode(raw_value) # type: ignore[no-any-return] + + # Handle structs + if struct and struct in structs: + return_tuple = return_value.return_value + return get_abi_struct_from_abi_tuple(return_tuple, structs[struct], structs) + + # Return as-is + return return_value.return_value # type: ignore[no-any-return] + + +def get_abi_encoded_value(value: Any, type_str: str, structs: dict[str, list[StructField]]) -> bytes: # noqa: ANN401, PLR0911 + if isinstance(value, (bytes | bytearray)): + return value + if type_str == "AVMUint64": + return ABIType.from_string("uint64").encode(value) + if type_str in ("AVMBytes", "AVMString"): + if isinstance(value, str): + return value.encode("utf-8") + if not isinstance(value, (bytes | bytearray)): + raise ValueError(f"Expected bytes value for {type_str}, but got {type(value)}") + return value + if type_str in structs: + tuple_type = get_abi_tuple_type_from_abi_struct_definition(structs[type_str], structs) + if isinstance(value, (list | tuple)): + return tuple_type.encode(value) # type: ignore[arg-type] + else: + tuple_values = get_abi_tuple_from_abi_struct(value, structs[type_str], structs) + return tuple_type.encode(tuple_values) + else: + abi_type = ABIType.from_string(type_str) + return abi_type.encode(value) + + +def get_abi_decoded_value( + value: bytes | int | str, type_str: str | ABITypeAlias | ABIArgumentType, structs: dict[str, list[StructField]] +) -> ABIValue: + type_value = str(type_str) + + if type_value == "AVMBytes" or not isinstance(value, bytes): + return value + if type_value == "AVMString": + return value.decode("utf-8") + if type_value == "AVMUint64": + return ABIType.from_string("uint64").decode(value) # type: ignore[no-any-return] + if type_value in structs: + tuple_type = get_abi_tuple_type_from_abi_struct_definition(structs[type_value], structs) + decoded_tuple = tuple_type.decode(value) + return get_abi_struct_from_abi_tuple(decoded_tuple, structs[type_value], structs) + return ABIType.from_string(type_value).decode(value) # type: ignore[no-any-return] + + +def get_abi_tuple_from_abi_struct( + struct_value: dict[str, Any], + struct_fields: list[StructField], + structs: dict[str, list[StructField]], +) -> list[Any]: + result = [] + for field in struct_fields: + key = field.name + if key not in struct_value: + raise ValueError(f"Missing value for field '{key}'") + value = struct_value[key] + field_type = field.type + if isinstance(field_type, str): + if field_type in structs: + value = get_abi_tuple_from_abi_struct(value, structs[field_type], structs) + elif isinstance(field_type, list): + value = get_abi_tuple_from_abi_struct(value, field_type, structs) + result.append(value) + return result + + +def get_abi_tuple_type_from_abi_struct_definition( + struct_def: list[StructField], structs: dict[str, list[StructField]] +) -> TupleType: + types = [] + for field in struct_def: + field_type = field.type + if isinstance(field_type, str): + if field_type in structs: + types.append(get_abi_tuple_type_from_abi_struct_definition(structs[field_type], structs)) + else: + types.append(ABIType.from_string(field_type)) # type: ignore[arg-type] + elif isinstance(field_type, list): + types.append(get_abi_tuple_type_from_abi_struct_definition(field_type, structs)) + else: + raise ValueError(f"Invalid field type: {field_type}") + return TupleType(types) + + +def get_abi_struct_from_abi_tuple( + decoded_tuple: Any, # noqa: ANN401 + struct_fields: list[StructField], + structs: dict[str, list[StructField]], +) -> dict[str, Any]: + result = {} + for i, field in enumerate(struct_fields): + key = field.name + field_type = field.type + value = decoded_tuple[i] + if isinstance(field_type, str): + if field_type in structs: + value = get_abi_struct_from_abi_tuple(value, structs[field_type], structs) + elif isinstance(field_type, list): + value = get_abi_struct_from_abi_tuple(value, field_type, structs) + result[key] = value + return result + + +def arc32_to_arc56(app_spec: ApplicationSpecification) -> Arc56Contract: # noqa: C901 + """ + Convert ARC-32 application specification to ARC-56 contract format. + + Args: + app_spec: ARC-32 application specification + + Returns: + ARC-56 contract specification + """ + + def convert_structs() -> dict[StructName, list[StructField]]: + structs: dict[StructName, list[StructField]] = {} + for hint in app_spec.hints.values(): + if not hint.structs: + continue + for struct in hint.structs.values(): + fields = [ + StructField( + name=name, + type=type_, + ) + for name, type_ in struct["elements"] + ] + structs[struct["name"]] = fields + return structs + + def get_hint(method: AlgorandABIMethod) -> MethodHints | None: + sig = method.get_signature() + return app_spec.hints.get(sig) + + def get_default_value( + type: str | ABIType, # noqa: A002 TODO: revisit + default_arg: DefaultArgumentDict, + ) -> DefaultValue | None: + if not default_arg or default_arg["source"] == "abi-method": + return None + + source_map = { + "constant": "literal", + "global-state": "global", + "local-state": "local", + } + + data = default_arg["data"] + if isinstance(data, str): + data = base64.b64encode(data.encode()).decode() + elif isinstance(data, bytes): + data = base64.b64encode(data).decode() + else: + data = str(data) + + return DefaultValue( + data=data, + type="AVMString" if type == "string" else str(type), + source=source_map.get(default_arg["source"], "literal"), # type: ignore[arg-type] + ) + + def convert_method(method: AlgorandABIMethod) -> Method: + hint = get_hint(method) + + args: list[MethodArg] = [] + for arg in method.args: + if not arg.name: + continue + struct_name = None + if hint and hint.structs and arg.name in hint.structs: + struct_name = hint.structs[arg.name].get("name") + + default_value = None + if hint and hint.default_arguments and arg.name in hint.default_arguments: + default_value = get_default_value(str(arg.type), hint.default_arguments[arg.name]) + + method_arg = MethodArg( + type=arg.type, # type: ignore[arg-type] + struct=struct_name, + name=arg.name, + desc=arg.desc, + default_value=default_value, + ) + args.append(method_arg) + + method_returns = MethodReturns( + type=str(method.returns.type), + struct=hint.structs.get("output", {}).get("name") if hint and hint.structs else None, # type: ignore[call-overload] + desc=method.returns.desc, + ) + + method_actions = MethodActions( + create=convert_actions(hint.call_config, "CREATE") if hint and hint.call_config else [], # type: ignore # noqa: PGH003 + call=convert_actions(hint.call_config, "CALL") if hint and hint.call_config else [], + ) + + return Method( + name=method.name, + desc=method.desc, + args=args, + returns=method_returns, + actions=method_actions, + readonly=hint.read_only if hint else False, + events=[], + recommendations=None, + ) + + def convert_storage_keys(schema_dict: AppSpecStateDict) -> dict[str, StorageKey]: + return { + name: StorageKey( + desc=spec.get("descr"), + key_type=spec["type"], + value_type="AVMUint64" if spec["type"] == "uint64" else "AVMBytes", + key=base64.b64encode(spec["key"].encode()).decode(), + ) + for name, spec in schema_dict.get("declared", {}).items() + } + + def convert_actions( + call_config: CallConfig | MethodConfigDict, action_type: Literal["CREATE", "CALL"] + ) -> list[OnCompleteAction | Literal["NoOp", "OptIn", "DeleteApplication"]]: + """ + Converts method configuration into a list of on-complete action literals. + + Args: + call_config (CallConfig | MethodConfigDict): Configuration dictionary or CallConfig object for method + actions. + action_type (Literal["CREATE", "CALL"]): The type of action to convert. + + Returns: + List[OnCompleteAction]: A list of on-complete action literals. + """ + config_action_map = { + "no_op": "NoOp", + "opt_in": "OptIn", + "close_out": "CloseOut", + "clear_state": "ClearState", + "update_application": "UpdateApplication", + "delete_application": "DeleteApplication", + } + + def get_action_value(key: str) -> str | None: + if isinstance(call_config, dict): + config_value = call_config.get(key) # type: ignore[call-overload] + # Handle legacy CallConfig enum + return config_value.name if hasattr(config_value, "name") else config_value # type: ignore[no-any-return] + # Handle new CallConfig dataclass + return getattr(call_config, key, None) + + return [action for key, action in config_action_map.items() if get_action_value(key) in ("ALL", action_type)] # type: ignore # noqa: PGH003 + + # Convert structs + structs = convert_structs() + + # Get schema information from app_spec + global_schema = app_spec.schema.get("global", {}) + local_schema = app_spec.schema.get("local", {}) + + state = Arc56ContractState( + schemas={ + "global": { + "ints": int(app_spec.global_state_schema.num_uints) if app_spec.global_state_schema.num_uints else 0, + "bytes": int(app_spec.global_state_schema.num_byte_slices) + if app_spec.global_state_schema.num_byte_slices + else 0, + }, + "local": { + "ints": int(app_spec.local_state_schema.num_uints) if app_spec.local_state_schema.num_uints else 0, + "bytes": int(app_spec.local_state_schema.num_byte_slices) + if app_spec.local_state_schema.num_byte_slices + else 0, + }, + }, + keys={ + "global": convert_storage_keys(global_schema), + "local": convert_storage_keys(local_schema), + "box": {}, + }, + maps={ + "global": {}, + "local": {}, + "box": {}, + }, + ) + + contract_source = { + "approval": app_spec.approval_program, + "clear": app_spec.clear_program, + } + + bare_actions = { + "create": convert_actions(app_spec.bare_call_config, "CREATE"), + "call": convert_actions(app_spec.bare_call_config, "CALL"), + } + + return Arc56Contract( + arcs=[], + name=app_spec.contract.name, + desc=app_spec.contract.desc, + structs=structs, + methods=[convert_method(m) for m in app_spec.contract.methods], + state=state, + source=contract_source, + bare_actions=bare_actions, + byte_code=None, + compiler_info=None, + events=None, + networks=None, + scratch_variables=None, + source_info=None, + template_variables=None, + ) diff --git a/src/algokit_utils/assets/asset_manager.py b/src/algokit_utils/assets/asset_manager.py index ee642dac..18184715 100644 --- a/src/algokit_utils/assets/asset_manager.py +++ b/src/algokit_utils/assets/asset_manager.py @@ -14,7 +14,7 @@ ) -@dataclass(frozen=True) +@dataclass(kw_only=True, frozen=True) class AccountAssetInformation: """Information about an account's holding of a particular asset.""" @@ -28,7 +28,7 @@ class AccountAssetInformation: """The round this information was retrieved at.""" -@dataclass(frozen=True) +@dataclass(kw_only=True, frozen=True) class AssetInformation: """Information about an asset.""" @@ -66,7 +66,7 @@ class AssetInformation: """32-byte hash of some metadata that is relevant to the asset and/or asset holders.""" -@dataclass(frozen=True) +@dataclass(kw_only=True, frozen=True) class BulkAssetOptInOutResult: """Individual result from performing a bulk opt-in or bulk opt-out for an account against a series of assets.""" diff --git a/src/algokit_utils/clients/algorand_client.py b/src/algokit_utils/clients/algorand_client.py index f679c95e..eb4ef73e 100644 --- a/src/algokit_utils/clients/algorand_client.py +++ b/src/algokit_utils/clients/algorand_client.py @@ -7,17 +7,11 @@ from typing_extensions import Self from algokit_utils.accounts.account_manager import AccountManager +from algokit_utils.applications.app_deployer import AppDeployer from algokit_utils.applications.app_manager import AppManager from algokit_utils.assets.asset_manager import AssetManager from algokit_utils.clients.client_manager import AlgoSdkClients, ClientManager -from algokit_utils.network_clients import ( - AlgoClientConfigs, - get_algod_client, - get_algonode_config, - get_default_localnet_config, - get_indexer_client, - get_kmd_client, -) +from algokit_utils.models.network import AlgoClientConfigs from algokit_utils.transactions.transaction_composer import ( AppCallParams, AppMethodCallParams, @@ -36,16 +30,16 @@ __all__ = [ "AlgorandClient", - "AssetCreateParams", - "AssetOptInParams", + "AppCallParams", "AppMethodCallParams", - "PaymentParams", - "AssetFreezeParams", "AssetConfigParams", + "AssetCreateParams", "AssetDestroyParams", - "AppCallParams", - "OnlineKeyRegistrationParams", + "AssetFreezeParams", + "AssetOptInParams", "AssetTransferParams", + "OnlineKeyRegistrationParams", + "PaymentParams", ] @@ -53,7 +47,7 @@ class AlgorandClient: """A client that brokers easy access to Algorand functionality.""" def __init__(self, config: AlgoClientConfigs | AlgoSdkClients): - self._client_manager: ClientManager = ClientManager(config) + self._client_manager: ClientManager = ClientManager(clients_or_configs=config, algorand_client=self) self._account_manager: AccountManager = AccountManager(self._client_manager) self._asset_manager: AssetManager = AssetManager(self._client_manager.algod, lambda: self.new_group()) self._app_manager: AppManager = AppManager(self._client_manager.algod) @@ -63,6 +57,9 @@ def __init__(self, config: AlgoClientConfigs | AlgoSdkClients): app_manager=self._app_manager, algod_client=self._client_manager.algod, ) + self._app_deployer: AppDeployer = AppDeployer( + self._app_manager, self._transaction_sender, self._client_manager.indexer_if_present + ) self._transaction_creator = AlgorandClientTransactionCreator( new_group=lambda: self.new_group(), ) @@ -163,10 +160,14 @@ def asset(self) -> AssetManager: return self._asset_manager @property - def app_deployer(self) -> AppManager: - """Get or create applications.""" + def app(self) -> AppManager: return self._app_manager + @property + def app_deployer(self) -> AppDeployer: + """Get or create applications.""" + return self._app_deployer + @property def send(self) -> AlgorandClientTransactionSender: """Methods for sending a transaction and waiting for confirmation""" @@ -192,9 +193,9 @@ def default_local_net() -> "AlgorandClient": """ return AlgorandClient( AlgoClientConfigs( - algod_config=get_default_localnet_config("algod"), - indexer_config=get_default_localnet_config("indexer"), - kmd_config=get_default_localnet_config("kmd"), + algod_config=ClientManager.get_default_local_net_config("algod"), + indexer_config=ClientManager.get_default_local_net_config("indexer"), + kmd_config=ClientManager.get_default_local_net_config("kmd"), ) ) @@ -207,8 +208,8 @@ def test_net() -> "AlgorandClient": """ return AlgorandClient( AlgoClientConfigs( - algod_config=get_algonode_config("testnet", "algod", ""), - indexer_config=get_algonode_config("testnet", "indexer", ""), + algod_config=ClientManager.get_algonode_config("testnet", "algod"), + indexer_config=ClientManager.get_algonode_config("testnet", "indexer"), kmd_config=None, ) ) @@ -222,8 +223,8 @@ def main_net() -> "AlgorandClient": """ return AlgorandClient( AlgoClientConfigs( - algod_config=get_algonode_config("mainnet", "algod", ""), - indexer_config=get_algonode_config("mainnet", "indexer", ""), + algod_config=ClientManager.get_algonode_config("mainnet", "algod"), + indexer_config=ClientManager.get_algonode_config("mainnet", "indexer"), kmd_config=None, ) ) @@ -249,13 +250,7 @@ def from_environment() -> "AlgorandClient": :return: The `AlgorandClient` """ - return AlgorandClient( - AlgoSdkClients( - algod=get_algod_client(), - kmd=get_kmd_client(), - indexer=get_indexer_client(), - ) - ) + return AlgorandClient(ClientManager.get_config_from_environment_or_localnet()) @staticmethod def from_config(config: AlgoClientConfigs) -> "AlgorandClient": diff --git a/src/algokit_utils/clients/client_manager.py b/src/algokit_utils/clients/client_manager.py index 16108520..ece39c6e 100644 --- a/src/algokit_utils/clients/client_manager.py +++ b/src/algokit_utils/clients/client_manager.py @@ -1,15 +1,22 @@ +import os +from dataclasses import dataclass +from typing import Literal +from urllib import parse + import algosdk +from algosdk.atomic_transaction_composer import TransactionSigner from algosdk.kmd import KMDClient from algosdk.v2client.algod import AlgodClient from algosdk.v2client.indexer import IndexerClient +# from algokit_utils.applications.app_factory import AppFactory, AppFactoryParams +from algokit_utils._legacy_v2.application_specification import ApplicationSpecification +from algokit_utils.applications.app_factory import AppFactory, AppFactoryParams +from algokit_utils.applications.app_manager import TealTemplateParams from algokit_utils.clients.dispenser_api_client import TestNetDispenserApiClient -from algokit_utils.network_clients import ( - AlgoClientConfigs, - get_algod_client, - get_indexer_client, - get_kmd_client, -) +from algokit_utils.models.application import Arc56Contract +from algokit_utils.models.network import AlgoClientConfig, AlgoClientConfigs +from algokit_utils.protocols.application import AlgorandClientProtocol class AlgoSdkClients: @@ -24,21 +31,48 @@ def __init__( self.kmd = kmd +@dataclass(kw_only=True, frozen=True) +class NetworkDetail: + is_test_net: bool + is_main_net: bool + is_local_net: bool + genesis_id: str + genesis_hash: str + + +def genesis_id_is_localnet(genesis_id: str) -> bool: + return genesis_id in ["devnet-v1", "sandnet-v1", "dockernet-v1"] + + +def _get_config_from_environment(environment_prefix: str) -> AlgoClientConfig: + server = os.getenv(f"{environment_prefix}_SERVER") + if server is None: + raise Exception(f"Server environment variable not set: {environment_prefix}_SERVER") + port = os.getenv(f"{environment_prefix}_PORT") + if port: + parsed = parse.urlparse(server) + server = parsed._replace(netloc=f"{parsed.hostname}:{port}").geturl() + return AlgoClientConfig(server, os.getenv(f"{environment_prefix}_TOKEN", "")) + + class ClientManager: - def __init__(self, clients_or_configs: AlgoClientConfigs | AlgoSdkClients): + def __init__(self, clients_or_configs: AlgoClientConfigs | AlgoSdkClients, algorand_client: AlgorandClientProtocol): if isinstance(clients_or_configs, AlgoSdkClients): _clients = clients_or_configs elif isinstance(clients_or_configs, AlgoClientConfigs): _clients = AlgoSdkClients( - algod=get_algod_client(clients_or_configs.algod_config), - indexer=get_indexer_client(clients_or_configs.indexer_config) + algod=ClientManager.get_algod_client(clients_or_configs.algod_config), + indexer=ClientManager.get_indexer_client(clients_or_configs.indexer_config) if clients_or_configs.indexer_config else None, - kmd=get_kmd_client(clients_or_configs.kmd_config) if clients_or_configs.kmd_config else None, + kmd=ClientManager.get_kmd_client(clients_or_configs.kmd_config) + if clients_or_configs.kmd_config + else None, ) self._algod = _clients.algod self._indexer = _clients.indexer self._kmd = _clients.kmd + self._algorand = algorand_client @property def algod(self) -> AlgodClient: @@ -52,6 +86,10 @@ def indexer(self) -> IndexerClient: raise ValueError("Attempt to use Indexer client in AlgoKit instance with no Indexer configured") return self._indexer + @property + def indexer_if_present(self) -> IndexerClient | None: + return self._indexer + @property def kmd(self) -> KMDClient: """Returns an algosdk KMD API client or raises an error if it's not been provided.""" @@ -59,6 +97,25 @@ def kmd(self) -> KMDClient: raise ValueError("Attempt to use Kmd client in AlgoKit instance with no Kmd configured") return self._kmd + def network(self) -> NetworkDetail: + sp = self._algod.suggested_params() # TODO: cache it + return NetworkDetail( + is_test_net=sp.gen in ["testnet-v1.0", "testnet-v1", "testnet"], + is_main_net=sp.gen in ["mainnet-v1.0", "mainnet-v1", "mainnet"], + is_local_net=ClientManager.genesis_id_is_local_net(str(sp.gen)), + genesis_id=str(sp.gen), + genesis_hash=sp.gh, + ) + + def is_local_net(self) -> bool: + return self.network().is_local_net + + def is_test_net(self) -> bool: + return self.network().is_test_net + + def is_main_net(self) -> bool: + return self.network().is_main_net + def get_testnet_dispenser( self, auth_token: str | None = None, request_timeout: int | None = None ) -> TestNetDispenserApiClient: @@ -66,3 +123,175 @@ def get_testnet_dispenser( return TestNetDispenserApiClient(auth_token=auth_token, request_timeout=request_timeout) return TestNetDispenserApiClient(auth_token=auth_token) + + def get_app_factory( + self, + app_spec: Arc56Contract | ApplicationSpecification | str, + app_name: str | None = None, + default_sender: str | None = None, + default_signer: TransactionSigner | None = None, + version: str | None = None, + updatable: bool | None = None, + deletable: bool | None = None, + deploy_time_params: TealTemplateParams | None = None, + ) -> AppFactory: + if not self._algorand: + raise ValueError("Attempt to get app factory from a ClientManager without an Algorand client") + + return AppFactory( + AppFactoryParams( + algorand=self._algorand, + app_spec=app_spec, + app_name=app_name, + default_sender=default_sender, + default_signer=default_signer, + version=version, + updatable=updatable, + deletable=deletable, + deploy_time_params=deploy_time_params, + ) + ) + + @staticmethod + def get_algod_client(config: AlgoClientConfig | None = None) -> AlgodClient: + """Returns an {py:class}`algosdk.v2client.algod.AlgodClient` from `config` or environment + + If no configuration provided will use environment variables `ALGOD_SERVER`, `ALGOD_PORT` and `ALGOD_TOKEN`""" + config = config or _get_config_from_environment("ALGOD") + headers = {"X-Algo-API-Token": config.token or ""} + return AlgodClient(algod_token=config.token or "", algod_address=config.server, headers=headers) + + @staticmethod + def get_algod_client_from_environment() -> AlgodClient: + return ClientManager.get_algod_client(ClientManager.get_algod_config_from_environment()) + + @staticmethod + def get_kmd_client(config: AlgoClientConfig | None = None) -> KMDClient: + """Returns an {py:class}`algosdk.kmd.KMDClient` from `config` or environment + + If no configuration provided will use environment variables `KMD_SERVER`, `KMD_PORT` and `KMD_TOKEN`""" + config = config or _get_config_from_environment("KMD") + return KMDClient(config.token, config.server) + + @staticmethod + def get_kmd_client_from_environment() -> KMDClient: + return ClientManager.get_kmd_client(ClientManager.get_kmd_config_from_environment()) + + @staticmethod + def get_indexer_client(config: AlgoClientConfig | None = None) -> IndexerClient: + """Returns an {py:class}`algosdk.v2client.indexer.IndexerClient` from `config` or environment. + + If no configuration provided will use environment variables `INDEXER_SERVER`, `INDEXER_PORT` and + `INDEXER_TOKEN`""" + config = config or _get_config_from_environment("INDEXER") + headers = {"X-Indexer-API-Token": config.token} + return IndexerClient(indexer_token=config.token, indexer_address=config.server, headers=headers) + + @staticmethod + def get_indexer_client_from_environment() -> IndexerClient: + return ClientManager.get_indexer_client(ClientManager.get_indexer_config_from_environment()) + + @staticmethod + def genesis_id_is_local_net(genesis_id: str) -> bool: + return genesis_id_is_localnet(genesis_id) + + @staticmethod + def get_config_from_environment_or_localnet() -> AlgoClientConfigs: + """Retrieve client configuration from environment variables or fallback to localnet defaults. + + If ALGOD_SERVER is set in environment variables, it will use environment configuration, + otherwise it will use default localnet configuration. + + Returns: + AlgoClientConfigs: Configuration for algod, indexer, and optionally kmd + """ + algod_server = os.getenv("ALGOD_SERVER") + + if algod_server: + # Use environment configuration + algod_config = ClientManager.get_algod_config_from_environment() + + # Only include indexer if INDEXER_SERVER is set + indexer_config = ( + ClientManager.get_indexer_config_from_environment() if os.getenv("INDEXER_SERVER") else None + ) + + # Include KMD config only for local networks (not mainnet/testnet) + kmd_config = ( + ClientManager.get_kmd_config_from_environment() + if not any(net in algod_server.lower() for net in ["mainnet", "testnet"]) + else None + ) + else: + # Use localnet defaults + algod_config = ClientManager.get_default_local_net_config("algod") + indexer_config = ClientManager.get_default_local_net_config("indexer") + kmd_config = ClientManager.get_default_local_net_config("kmd") + + return AlgoClientConfigs( + algod_config=algod_config, + indexer_config=indexer_config, + kmd_config=kmd_config, + ) + + @staticmethod + def get_default_local_net_config(config_or_port: Literal["algod", "indexer", "kmd"] | int) -> AlgoClientConfig: + port = ( + config_or_port + if isinstance(config_or_port, int) + else {"algod": 4001, "indexer": 8980, "kmd": 4002}[config_or_port] + ) + + return AlgoClientConfig(server=f"http://localhost:{port}", token="a" * 64) + + @staticmethod + def get_algod_config_from_environment() -> AlgoClientConfig: + """Retrieve the algod configuration from environment variables. + + Expects ALGOD_SERVER to be defined in environment variables. + ALGOD_PORT and ALGOD_TOKEN are optional. + + Raises: + ValueError: If ALGOD_SERVER environment variable is not set + """ + return _get_config_from_environment("ALGOD") + + @staticmethod + def get_indexer_config_from_environment() -> AlgoClientConfig: + """Retrieve the indexer configuration from environment variables. + + Expects INDEXER_SERVER to be defined in environment variables. + INDEXER_PORT and INDEXER_TOKEN are optional. + + Raises: + ValueError: If INDEXER_SERVER environment variable is not set + """ + return _get_config_from_environment("INDEXER") + + @staticmethod + def get_kmd_config_from_environment() -> AlgoClientConfig: + """Retrieve the kmd configuration from environment variables. + + Expects KMD_SERVER to be defined in environment variables. + KMD_PORT and KMD_TOKEN are optional. + """ + return _get_config_from_environment("KMD") + + @staticmethod + def get_algonode_config( + network: Literal["testnet", "mainnet"], config: Literal["algod", "indexer"] + ) -> AlgoClientConfig: + """Returns the Algorand configuration to point to the free tier of the AlgoNode service. + + Args: + network: Which network to connect to - TestNet or MainNet + config: Which algod config to return - Algod or Indexer + + Returns: + AlgoClientConfig: Configuration for the specified network and service + """ + service_type = "api" if config == "algod" else "idx" + return AlgoClientConfig( + server=f"https://{network}-{service_type}.algonode.cloud", + port=443, + ) diff --git a/src/algokit_utils/clients/dispenser_api_client.py b/src/algokit_utils/clients/dispenser_api_client.py index 66593e80..b8a3ef78 100644 --- a/src/algokit_utils/clients/dispenser_api_client.py +++ b/src/algokit_utils/clients/dispenser_api_client.py @@ -1,12 +1,13 @@ import contextlib import enum -import logging import os from dataclasses import dataclass import httpx -logger = logging.getLogger(__name__) +from algokit_utils.config import config + +logger = config.logger class DispenserApiConfig: diff --git a/src/algokit_utils/config.py b/src/algokit_utils/config.py index 55850fd0..f76704ce 100644 --- a/src/algokit_utils/config.py +++ b/src/algokit_utils/config.py @@ -2,6 +2,7 @@ import os from collections.abc import Callable from pathlib import Path +from typing import Any logger = logging.getLogger(__name__) @@ -10,6 +11,50 @@ ALGOKIT_CONFIG_FILENAME = ".algokit.toml" +class AlgoKitLogger: + def __init__(self) -> None: + self._logger = logging.getLogger("algokit") + self._setup_logger() + + def _setup_logger(self) -> None: + formatter = logging.Formatter("%(levelname)s: %(message)s") + handler = logging.StreamHandler() + handler.setFormatter(formatter) + self._logger.addHandler(handler) + self._logger.setLevel(logging.INFO) + + def _get_logger(self, *, suppress_log: bool = False) -> logging.Logger: + if suppress_log: + null_logger = logging.getLogger("null") + null_logger.addHandler(logging.NullHandler()) + return null_logger + return self._logger + + def error(self, message: str, *args: Any, suppress_log: bool = False, **kwargs: Any) -> None: + """Log an error message, optionally suppressing output""" + self._get_logger(suppress_log=suppress_log).error(message, *args, **kwargs) + + def exception(self, message: str, *args: Any, suppress_log: bool = False, **kwargs: Any) -> None: + """Log an exception message, optionally suppressing output""" + self._get_logger(suppress_log=suppress_log).exception(message, *args, **kwargs) + + def warning(self, message: str, *args: Any, suppress_log: bool = False, **kwargs: Any) -> None: + """Log a warning message, optionally suppressing output""" + self._get_logger(suppress_log=suppress_log).warning(message, *args, **kwargs) + + def info(self, message: str, *args: Any, suppress_log: bool = False, **kwargs: Any) -> None: + """Log an info message, optionally suppressing output""" + self._get_logger(suppress_log=suppress_log).info(message, *args, **kwargs) + + def debug(self, message: str, *args: Any, suppress_log: bool = False, **kwargs: Any) -> None: + """Log a debug message, optionally suppressing output""" + self._get_logger(suppress_log=suppress_log).debug(message, *args, **kwargs) + + def verbose(self, message: str, *args: Any, suppress_log: bool = False, **kwargs: Any) -> None: + """Log a verbose message (maps to debug), optionally suppressing output""" + self._get_logger(suppress_log=suppress_log).debug(message, *args, **kwargs) + + class UpdatableConfig: """Class to manage and update configuration settings for the AlgoKit project. @@ -19,26 +64,33 @@ class UpdatableConfig: trace_all (bool): Indicates whether to trace all operations. trace_buffer_size_mb (int): The size of the trace buffer in megabytes. max_search_depth (int): The maximum depth to search for a specific file. + populate_app_call_resources (bool): Indicates whether to populate app call resources. """ def __init__(self) -> None: + self._logger = AlgoKitLogger() self._debug: bool = False self._project_root: Path | None = None self._trace_all: bool = False self._trace_buffer_size_mb: int | float = 256 # megabytes self._max_search_depth: int = 10 + self._populate_app_call_resources: bool = False self._configure_project_root() def _configure_project_root(self) -> None: """Configures the project root by searching for a specific file within a depth limit.""" current_path = Path(__file__).resolve() for _ in range(self._max_search_depth): - logger.debug(f"Searching in: {current_path}") + self.logger.debug(f"Searching in: {current_path}") if (current_path / ALGOKIT_CONFIG_FILENAME).exists(): self._project_root = current_path break current_path = current_path.parent + @property + def logger(self) -> AlgoKitLogger: + return self._logger + @property def debug(self) -> bool: """Returns the debug status.""" @@ -59,6 +111,10 @@ def trace_buffer_size_mb(self) -> int | float: """Returns the size of the trace buffer in megabytes.""" return self._trace_buffer_size_mb + @property + def populate_app_call_resource(self) -> bool: + return self._populate_app_call_resources + def with_debug(self, func: Callable[[], str | None]) -> None: """Executes a function with debug mode temporarily enabled.""" original_debug = self._debug @@ -68,7 +124,7 @@ def with_debug(self, func: Callable[[], str | None]) -> None: finally: self._debug = original_debug - def configure( # noqa: PLR0913 + def configure( self, *, debug: bool, diff --git a/src/algokit_utils/errors/logic_error.py b/src/algokit_utils/errors/logic_error.py new file mode 100644 index 00000000..24fb40a0 --- /dev/null +++ b/src/algokit_utils/errors/logic_error.py @@ -0,0 +1,129 @@ +import base64 +import dataclasses +import re +from collections.abc import Callable +from copy import copy +from typing import TYPE_CHECKING, TypedDict + +from algosdk.atomic_transaction_composer import ( + SimulateAtomicTransactionResponse, +) + +if TYPE_CHECKING: + from algosdk.source_map import SourceMap as AlgoSourceMap + +__all__ = [ + "LogicError", + "parse_logic_error", +] + +LOGIC_ERROR = ( + ".*transaction (?P[A-Z0-9]+): logic eval error: (?P.*). Details: .*pc=(?P[0-9]+).*" +) + +DEFAULT_BLAST_RADIUS = 5 + + +class LogicErrorData(TypedDict): + transaction_id: str + message: str + pc: int + + +@dataclasses.dataclass +class SimulationTrace: + app_budget_added: int | None + app_budget_consumed: int | None + failure_message: str | None + exec_trace: dict[str, object] + + +def parse_logic_error( + error_str: str, +) -> LogicErrorData | None: + match = re.match(LOGIC_ERROR, error_str) + if match is None: + return None + + return { + "transaction_id": match.group("transaction_id"), + "message": match.group("message"), + "pc": int(match.group("pc")), + } + + +class LogicError(Exception): + def __init__( + self, + *, + logic_error_str: str, + program: str, + source_map: "AlgoSourceMap | None", + transaction_id: str, + message: str, + pc: int, + logic_error: Exception | None = None, + traces: list[SimulationTrace] | None = None, + get_line_for_pc: Callable[[int], int | None] | None = None, + ): + self.logic_error = logic_error + self.logic_error_str = logic_error_str + try: + self.program = base64.b64decode(program).decode("utf-8") + except Exception: + self.program = program + self.source_map = source_map + self.lines = self.program.split("\n") + self.transaction_id = transaction_id + self.message = message + self.pc = pc + self.traces = traces + self.line_no = ( + self.source_map.get_line_for_pc(self.pc) + if self.source_map + else get_line_for_pc(self.pc) + if get_line_for_pc + else None + ) + + def __str__(self) -> str: + return ( + f"Txn {self.transaction_id} had error '{self.message}' at PC {self.pc}" + + (":" if self.line_no is None else f" and Source Line {self.line_no}:") + + f"\n{self.trace()}" + ) + + def trace(self, lines: int = 5) -> str: + if self.line_no is None: + return """ +Could not determine TEAL source line for the error as no approval source map was provided, to receive a trace of the +error please provide an approval SourceMap. Either by: + 1.Providing template_values when creating the ApplicationClient, so a SourceMap can be obtained automatically OR + 2.Set approval_source_map from a previously compiled approval program OR + 3.Import a previously exported source map using import_source_map""" + + program_lines = copy(self.lines) + program_lines[self.line_no] += "\t\t<-- Error" + lines_before = max(0, self.line_no - lines) + lines_after = min(len(program_lines), self.line_no + lines) + return "\n\t" + "\n\t".join(program_lines[lines_before:lines_after]) + + +def create_simulate_traces_for_logic_error(simulate: SimulateAtomicTransactionResponse) -> list[SimulationTrace]: + traces = [] + if hasattr(simulate, "simulate_response") and hasattr(simulate, "failed_at") and simulate.failed_at: + for txn_group in simulate.simulate_response["txn-groups"]: + app_budget_added = txn_group.get("app-budget-added", None) + app_budget_consumed = txn_group.get("app-budget-consumed", None) + failure_message = txn_group.get("failure-message", None) + txn_result = txn_group.get("txn-results", [{}])[0] + exec_trace = txn_result.get("exec-trace", {}) + traces.append( + SimulationTrace( + app_budget_added=app_budget_added, + app_budget_consumed=app_budget_consumed, + failure_message=failure_message, + exec_trace=exec_trace, + ) + ) + return traces diff --git a/src/algokit_utils/models/abi.py b/src/algokit_utils/models/abi.py index 767eed09..4e837274 100644 --- a/src/algokit_utils/models/abi.py +++ b/src/algokit_utils/models/abi.py @@ -1,4 +1,14 @@ +from typing import TypeAlias + +import algosdk + +from algokit_utils.models.application import StructField + ABIPrimitiveValue = bool | int | str | bytes | bytearray # NOTE: This is present in js-algorand-sdk, but sadly not in untyped py-algorand-sdk -ABIValue = ABIPrimitiveValue | list["ABIValue"] | dict[str, "ABIValue"] +ABIValue: TypeAlias = ABIPrimitiveValue | list["ABIValue"] | tuple["ABIValue"] | dict[str, "ABIValue"] +ABIStruct: TypeAlias = dict[str, list[StructField]] + + +ABIType: TypeAlias = algosdk.abi.ABIType diff --git a/src/algokit_utils/models/account.py b/src/algokit_utils/models/account.py index 3014b7af..f83cc1e2 100644 --- a/src/algokit_utils/models/account.py +++ b/src/algokit_utils/models/account.py @@ -3,6 +3,8 @@ import algosdk from algosdk.atomic_transaction_composer import AccountTransactionSigner +DISPENSER_ACCOUNT_NAME = "DISPENSER" + @dataclasses.dataclass(kw_only=True) class Account: @@ -15,7 +17,7 @@ class Account: def __post_init__(self) -> None: if not self.address: - self.address = algosdk.account.address_from_private_key(self.private_key) + self.address = str(algosdk.account.address_from_private_key(self.private_key)) @property def public_key(self) -> bytes: diff --git a/src/algokit_utils/models/amount.py b/src/algokit_utils/models/amount.py index ac86cd3b..adb7ffae 100644 --- a/src/algokit_utils/models/amount.py +++ b/src/algokit_utils/models/amount.py @@ -121,3 +121,27 @@ def __ge__(self, other: object) -> bool: elif isinstance(other, int | Decimal): return self.amount_in_micro_algo >= int(other) raise TypeError(f"Unsupported operand type(s) for >=: 'AlgoAmount' and '{type(other).__name__}'") + + def __sub__(self, other: int | Decimal | AlgoAmount) -> AlgoAmount: + if isinstance(other, AlgoAmount): + total_micro_algos = self.micro_algos - other.micro_algos + elif isinstance(other, (int | Decimal)): + total_micro_algos = self.micro_algos - int(other) + else: + raise TypeError(f"Unsupported operand type(s) for -: 'AlgoAmount' and '{type(other).__name__}'") + return AlgoAmount.from_micro_algos(total_micro_algos) + + def __rsub__(self, other: int | Decimal) -> AlgoAmount: + if isinstance(other, (int | Decimal)): + total_micro_algos = int(other) - self.micro_algos + return AlgoAmount.from_micro_algos(total_micro_algos) + raise TypeError(f"Unsupported operand type(s) for -: '{type(other).__name__}' and 'AlgoAmount'") + + def __isub__(self, other: int | Decimal | AlgoAmount) -> Self: + if isinstance(other, AlgoAmount): + self.amount_in_micro_algo -= other.micro_algos + elif isinstance(other, (int | Decimal)): + self.amount_in_micro_algo -= int(other) + else: + raise TypeError(f"Unsupported operand type(s) for -: 'AlgoAmount' and '{type(other).__name__}'") + return self diff --git a/src/algokit_utils/models/application.py b/src/algokit_utils/models/application.py index c68e78af..6ab5d0ff 100644 --- a/src/algokit_utils/models/application.py +++ b/src/algokit_utils/models/application.py @@ -1,5 +1,469 @@ +import json +from dataclasses import asdict, dataclass, field, is_dataclass +from typing import Any, Literal, TypeAlias + +import algosdk + UPDATABLE_TEMPLATE_NAME = "TMPL_UPDATABLE" """The name of the TEAL template variable for deploy-time immutability control.""" DELETABLE_TEMPLATE_NAME = "TMPL_DELETABLE" """The name of the TEAL template variable for deploy-time permanence control.""" + + +# ===== ARCs ===== + +# Define type aliases +ABITypeAlias: TypeAlias = str +ABIArgumentType: TypeAlias = algosdk.abi.ABIType | algosdk.abi.ABITransactionType | algosdk.abi.ABIReferenceType +StructName: TypeAlias = str +AVMBytes = Literal["AVMBytes"] +AVMString = Literal["AVMString"] +AVMUint64 = Literal["AVMUint64"] +AVMType = AVMBytes | AVMString | AVMUint64 +OnCompleteAction = Literal["NoOp", "OptIn", "CloseOut", "ClearState", "UpdateApplication", "DeleteApplication"] +DefaultValueSource = Literal["box", "global", "local", "literal", "method"] + + +def convert_key_to_snake_case(name: str) -> str: + import re + + return re.sub(r"(? Any: # noqa: ANN401 + if isinstance(obj, dict): + return {convert_key_to_snake_case(k): convert_keys_to_snake_case(v) for k, v in obj.items()} + elif isinstance(obj, list): + return [convert_keys_to_snake_case(item) for item in obj] + return obj + + +class SerializableBaseClass: + """ + A base class that provides a generic `dictify` method to convert dataclass instances + into dictionaries recursively. + """ + + def to_dict(self) -> dict[str, Any]: + def serialize(obj: Any) -> dict[str, Any] | list[Any] | Any: # noqa: ANN401 + if is_dataclass(obj) and not isinstance(obj, type): + return {k: serialize(v) for k, v in asdict(obj).items()} + elif isinstance(obj, algosdk.abi.ABIType): + return str(obj) + elif isinstance(obj, list): + return [serialize(item) for item in obj] + elif isinstance(obj, dict): + return {k: serialize(v) for k, v in obj.items()} + else: + return obj + + result = serialize(self) + if not isinstance(result, dict): + raise TypeError("Serialized object is not a dictionary.") + return result + + +@dataclass +class CallConfig: + no_op: str | None = None + opt_in: str | None = None + close_out: str | None = None + clear_state: str | None = None + update_application: str | None = None + delete_application: str | None = None + + +@dataclass(kw_only=True) +class StructField: + name: str + type: ABITypeAlias | StructName | list["StructField"] + + +@dataclass(kw_only=True) +class StorageKey: + desc: str | None + key_type: ABITypeAlias | AVMType | StructName + value_type: ABITypeAlias | AVMType | StructName + key: str # base64 encoded bytes + + +@dataclass(kw_only=True) +class StorageMap: + desc: str | None + key_type: ABITypeAlias | AVMType | StructName + value_type: ABITypeAlias | AVMType | StructName + prefix: str | None # base64-encoded prefix + + +@dataclass(kw_only=True) +class DefaultValue: + data: str + type: ABITypeAlias | AVMType | None = None + source: DefaultValueSource + + +@dataclass(kw_only=True) +class MethodArg: + type: ABITypeAlias + struct: StructName | None = None + name: str | None = None + desc: str | None = None + default_value: DefaultValue | None = None + + +@dataclass +class MethodReturns: + type: ABITypeAlias + struct: StructName | None = None + desc: str | None = None + + +@dataclass(kw_only=True) +class MethodActions: + create: list[Literal["NoOp", "OptIn", "DeleteApplication"]] + call: list[Literal["NoOp", "OptIn", "CloseOut", "ClearState", "UpdateApplication", "DeleteApplication"]] + + +@dataclass(kw_only=True) +class BoxRecommendation: + app: int | None = None + key: str = "" + read_bytes: int = 0 + write_bytes: int = 0 + + +@dataclass(kw_only=True) +class Recommendations: + inner_transaction_count: int | None = None + boxes: list[BoxRecommendation] | None = None + accounts: list[str] | None = None + apps: list[int] | None = None + assets: list[int] | None = None + + +@dataclass(kw_only=True) +class Method(SerializableBaseClass): + name: str + desc: str | None = None + args: list[MethodArg] = field(default_factory=list) + returns: MethodReturns = field(default_factory=lambda: MethodReturns(type="void")) + actions: MethodActions = field(default_factory=lambda: MethodActions(create=[], call=[])) + readonly: bool | None = False + events: list["Event"] | None = None + recommendations: Recommendations | None = None + + +@dataclass(kw_only=True) +class EventArg: + type: ABITypeAlias + name: str | None = None + desc: str | None = None + struct: StructName | None = None + + +@dataclass(kw_only=True) +class Event: + name: str + desc: str | None = None + args: list[EventArg] = field(default_factory=list) + + +@dataclass(kw_only=True) +class CompilerVersion: + major: int + minor: int + patch: int + commit_hash: str | None = None + + +@dataclass(kw_only=True) +class CompilerInfo: + compiler: Literal["algod", "puya"] + compiler_version: CompilerVersion + + +@dataclass +class SourceInfoDetail: + pc: list[int] + error_message: str | None = None + teal: int | None = None + source: str | None = None + + +@dataclass(kw_only=True) +class ProgramSourceInfo: + source_info: list[SourceInfoDetail] + pc_offset_method: Literal["none", "cblocks"] + + @staticmethod + def from_json(source_info: str | dict) -> "ProgramSourceInfo": + if "source_info" not in source_info: + raise ValueError("source_info is required") + source_dict: dict = json.loads(source_info) if isinstance(source_info, str) else source_info + parsed_source_dict = [SourceInfoDetail(**detail) for detail in source_dict["source_info"]] + return ProgramSourceInfo(source_info=parsed_source_dict, pc_offset_method=source_dict["pc_offset_method"]) + + +@dataclass(kw_only=True) +class Arc56ContractState: + keys: dict[str, dict[str, StorageKey]] + maps: dict[str, dict[str, StorageMap]] + schemas: dict[str, dict[str, int]] + + +@dataclass(kw_only=True) +class Arc56MethodArg: + """Represents an ARC-56 method argument with ABI type conversion.""" + + name: str | None = None + desc: str | None = None + struct: StructName | None = None + default_value: DefaultValue | None = None + type: ABIArgumentType + + @classmethod + def from_method_arg(cls, arg: MethodArg, converted_type: ABIArgumentType) -> "Arc56MethodArg": + """Create an Arc56MethodArg from a MethodArg with converted type.""" + return cls( + name=arg.name, + desc=arg.desc, + struct=arg.struct, + default_value=arg.default_value, + type=converted_type, + ) + + +@dataclass(kw_only=True) +class Arc56MethodReturnType: + """Represents an ARC-56 method return type with ABI type conversion.""" + + type: algosdk.abi.ABIType | Literal["void"] # Can be 'void' or ABIType + struct: StructName | None = None + desc: str | None = None + + +class Arc56Method(SerializableBaseClass, algosdk.abi.Method): + def __init__(self, method: Method) -> None: + # First, create the parent class with original arguments + super().__init__( + name=method.name, + args=method.args, # type: ignore[arg-type] + returns=algosdk.abi.Returns(arg_type=method.returns.type, desc=method.returns.desc), + desc=method.desc, + ) + self.method = method + + # Store our custom Arc56MethodArg list separately + + self._arc56_args = [ + Arc56MethodArg.from_method_arg( + arg, + algosdk.abi.ABIType.from_string(arg.type) + if not self._is_transaction_or_reference_type(arg.type) and isinstance(arg.type, str) + else arg.type, # type: ignore[arg-type] + ) + for arg in method.args + ] + + # Convert returns similar to TypeScript implementation, including struct support + converted_return_type: Literal["void"] | algosdk.abi.ABIType + if method.returns.type == "void": + converted_return_type = "void" + else: + converted_return_type = algosdk.abi.ABIType.from_string(str(method.returns.type)) + + self._arc56_returns = Arc56MethodReturnType( + type=converted_return_type, + struct=method.returns.struct, + desc=method.returns.desc, + ) + + def _is_transaction_or_reference_type(self, type_str: str) -> bool: + return type_str in [ + algosdk.constants.ASSETCONFIG_TXN, + algosdk.constants.PAYMENT_TXN, + algosdk.constants.KEYREG_TXN, + algosdk.constants.ASSETFREEZE_TXN, + algosdk.constants.ASSETTRANSFER_TXN, + algosdk.constants.APPCALL_TXN, + algosdk.constants.STATEPROOF_TXN, + algosdk.abi.ABIReferenceType.APPLICATION, + algosdk.abi.ABIReferenceType.ASSET, + algosdk.abi.ABIReferenceType.ACCOUNT, + ] + + @property + def arc56_args(self) -> list[Arc56MethodArg]: + """Get the ARC-56 specific argument representations.""" + return self._arc56_args + + @property + def arc56_returns(self) -> Arc56MethodReturnType: + """Get the ARC-56 specific returns type, including struct information.""" + return self._arc56_returns + + +@dataclass(kw_only=True) +class Arc56Contract(SerializableBaseClass): + arcs: list[int] + name: str + desc: str | None = None + networks: dict[str, dict[str, int]] | None = None + structs: dict[StructName, list[StructField]] = field(default_factory=dict) + methods: list[Method] = field(default_factory=list) + state: Arc56ContractState + bare_actions: dict[str, list[OnCompleteAction]] = field(default_factory=dict) + source_info: dict[str, ProgramSourceInfo] | None = None + source: dict[str, str] | None = None + byte_code: dict[str, str] | None = None + compiler_info: CompilerInfo | None = None + events: list[Event] | None = None + template_variables: dict[str, dict[str, ABITypeAlias | AVMType | StructName | str]] | None = None + scratch_variables: dict[str, dict[str, int | ABITypeAlias | AVMType | StructName]] | None = None + + @staticmethod + def from_json(application_spec: str | dict) -> "Arc56Contract": + """Convert a JSON dictionary into an Arc56Contract instance. + + Args: + json_data (dict): The JSON data representing an Arc56Contract + + Returns: + Arc56Contract: The constructed Arc56Contract instance + """ + # Convert networks if present + json_data = json.loads(application_spec) if isinstance(application_spec, str) else application_spec + json_data = convert_keys_to_snake_case(json_data) + networks = json_data.get("networks") + + # Convert structs + structs = { + name: [StructField(**field) if isinstance(field, dict) else field for field in struct_fields] + for name, struct_fields in json_data.get("structs", {}).items() + } + + # Convert methods + methods = [] + for method_data in json_data.get("methods", []): + # Convert method args + args = [MethodArg(**arg) for arg in method_data.get("args", [])] + + # Convert method returns + returns_data = method_data.get("returns", {"type": "void"}) + returns = MethodReturns(**returns_data) + + # Convert method actions + actions_data = method_data.get("actions", {"create": [], "call": []}) + actions = MethodActions(**actions_data) + + # Convert events if present + events = None + if "events" in method_data: + events = [Event(**event) for event in method_data["events"]] + + # Convert recommendations if present + recommendations = None + if "recommendations" in method_data: + recommendations = Recommendations(**method_data["recommendations"]) + + methods.append( + Method( + name=method_data["name"], + desc=method_data.get("desc"), + args=args, + returns=returns, + actions=actions, + readonly=method_data.get("readonly", False), + events=events, + recommendations=recommendations, + ) + ) + + # Convert state + state_data = json_data["state"] + state = Arc56ContractState( + keys={ + category: {name: StorageKey(**key_data) for name, key_data in keys.items()} + for category, keys in state_data.get("keys", {}).items() + }, + maps={ + category: {name: StorageMap(**map_data) for name, map_data in maps.items()} + for category, maps in state_data.get("maps", {}).items() + }, + schemas=state_data.get("schema", {}), + ) + + # Convert compiler info if present + compiler_info = None + if "compiler_info" in json_data: + compiler_version = CompilerVersion(**json_data["compiler_info"]["compiler_version"]) + compiler_info = CompilerInfo( + compiler=json_data["compiler_info"]["compiler"], compiler_version=compiler_version + ) + + # Convert events if present + events = None + if "events" in json_data: + events = [Event(**event) for event in json_data["events"]] + + source_info = {} + if "source_info" in json_data: + source_info = {key: ProgramSourceInfo.from_json(val) for key, val in json_data["source_info"].items()} + + return Arc56Contract( + arcs=json_data.get("arcs", []), + name=json_data["name"], + desc=json_data.get("desc"), + networks=networks, + structs=structs, + methods=methods, + state=state, + bare_actions=json_data.get("bare_actions", {}), + source_info=source_info, + source=json_data.get("source"), + byte_code=json_data.get("byte_code"), + compiler_info=compiler_info, + events=events, + template_variables=json_data.get("template_variables"), + scratch_variables=json_data.get("scratch_variables"), + ) + + +@dataclass(kw_only=True, frozen=True) +class AppState: + key_raw: bytes + key_base64: str + value_raw: bytes | None + value_base64: str | None + value: str | int + + +@dataclass(kw_only=True, frozen=True) +class AppInformation: + app_id: int + app_address: str + approval_program: bytes + clear_state_program: bytes + creator: str + global_state: dict[str, AppState] + local_ints: int + local_byte_slices: int + global_ints: int + global_byte_slices: int + extra_program_pages: int | None + + +@dataclass(kw_only=True, frozen=True) +class CompiledTeal: + teal: str + compiled: bytes + compiled_hash: str + compiled_base64_to_bytes: bytes + source_map: algosdk.source_map.SourceMap | None + + +@dataclass(kw_only=True, frozen=True) +class AppCompilationResult: + compiled_approval: CompiledTeal + compiled_clear: CompiledTeal diff --git a/src/algokit_utils/models/network.py b/src/algokit_utils/models/network.py new file mode 100644 index 00000000..8ee897e2 --- /dev/null +++ b/src/algokit_utils/models/network.py @@ -0,0 +1,20 @@ +import dataclasses + + +@dataclasses.dataclass +class AlgoClientConfig: + """Connection details for connecting to an {py:class}`algosdk.v2client.algod.AlgodClient` or + {py:class}`algosdk.v2client.indexer.IndexerClient`""" + + server: str + """URL for the service e.g. `http://localhost:4001` or `https://testnet-api.algonode.cloud`""" + token: str | None = None + """API Token to authenticate with the service""" + port: str | int | None = None + + +@dataclasses.dataclass +class AlgoClientConfigs: + algod_config: AlgoClientConfig + indexer_config: AlgoClientConfig | None + kmd_config: AlgoClientConfig | None diff --git a/src/algokit_utils/models/transaction.py b/src/algokit_utils/models/transaction.py new file mode 100644 index 00000000..ca8c0844 --- /dev/null +++ b/src/algokit_utils/models/transaction.py @@ -0,0 +1,8 @@ +from dataclasses import dataclass + + +@dataclass(kw_only=True, frozen=True) +class SendParams: + max_rounds_to_wait: int | None = None + suppress_log: bool | None = None + populate_app_call_resources: bool | None = None diff --git a/src/algokit_utils/protocols/__init__.py b/src/algokit_utils/protocols/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/algokit_utils/protocols/application.py b/src/algokit_utils/protocols/application.py new file mode 100644 index 00000000..c4782162 --- /dev/null +++ b/src/algokit_utils/protocols/application.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Protocol + +from typing_extensions import runtime_checkable + +if TYPE_CHECKING: + from algosdk.v2client.algod import AlgodClient + from algosdk.v2client.indexer import IndexerClient + + from algokit_utils.applications.app_deployer import AppDeployer + from algokit_utils.applications.app_manager import AppManager + from algokit_utils.clients.client_manager import ClientManager + from algokit_utils.transactions.transaction_composer import TransactionComposer + from algokit_utils.transactions.transaction_creator import AlgorandClientTransactionCreator + from algokit_utils.transactions.transaction_sender import AlgorandClientTransactionSender + + +@dataclass +class NetworkDetails: + genesis_id: str + genesis_hash: str + network_name: str + + +@runtime_checkable +class AlgorandClientProtocol(Protocol): + @property + def app(self) -> AppManager: ... + + @property + def app_deployer(self) -> AppDeployer: ... + + @property + def send(self) -> AlgorandClientTransactionSender: ... + + @property + def create_transaction(self) -> AlgorandClientTransactionCreator: ... + + def new_group(self) -> TransactionComposer: ... + + @property + def client(self) -> ClientManager: ... + + +@runtime_checkable +class ClientManagerProtocol(Protocol): + @property + def algod(self) -> AlgodClient: ... + + @property + def indexer(self) -> IndexerClient | None: ... + + async def network(self) -> NetworkDetails: ... + + async def is_local_net(self) -> bool: ... + + async def is_test_net(self) -> bool: ... + + async def is_main_net(self) -> bool: ... diff --git a/src/algokit_utils/transactions/models.py b/src/algokit_utils/transactions/models.py index 251bbf96..33edd94c 100644 --- a/src/algokit_utils/transactions/models.py +++ b/src/algokit_utils/transactions/models.py @@ -1,4 +1,6 @@ -from typing import Any, Literal, TypedDict +from typing import Any, Literal, TypedDict, TypeVar, cast + +import algosdk # Define specific types for different formats @@ -28,3 +30,51 @@ class JsonFormatArc2Note(BaseArc2Note): TransactionNoteData = str | None | int | list[Any] | dict[str, Any] TransactionNote = bytes | TransactionNoteData | Arc2TransactionNote + +T = TypeVar("T") + + +class TransactionWrapper(algosdk.transaction.Transaction): + """Wrapper around algosdk.transaction.Transaction with optional property validators""" + + def __init__(self, transaction: algosdk.transaction.Transaction) -> None: + self._raw = transaction + + @property + def raw(self) -> algosdk.transaction.Transaction: + return self._raw + + @property + def payment(self) -> algosdk.transaction.PaymentTxn | None: + return self._return_if_type( + algosdk.transaction.PaymentTxn, + ) + + @property + def keyreg(self) -> algosdk.transaction.KeyregTxn | None: + return self._return_if_type(algosdk.transaction.KeyregTxn) + + @property + def asset_config(self) -> algosdk.transaction.AssetConfigTxn | None: + return self._return_if_type(algosdk.transaction.AssetConfigTxn) + + @property + def asset_transfer(self) -> algosdk.transaction.AssetTransferTxn | None: + return self._return_if_type(algosdk.transaction.AssetTransferTxn) + + @property + def asset_freeze(self) -> algosdk.transaction.AssetFreezeTxn | None: + return self._return_if_type(algosdk.transaction.AssetFreezeTxn) + + @property + def application_call(self) -> algosdk.transaction.ApplicationCallTxn | None: + return self._return_if_type(algosdk.transaction.ApplicationCallTxn) + + @property + def state_proof(self) -> algosdk.transaction.StateProofTxn | None: + return self._return_if_type(algosdk.transaction.StateProofTxn) + + def _return_if_type(self, txn_type: type[T]) -> T | None: + if isinstance(self._raw, txn_type): + return cast(T, self._raw) + return None diff --git a/src/algokit_utils/transactions/transaction_composer.py b/src/algokit_utils/transactions/transaction_composer.py index 77dea2e9..7d66c939 100644 --- a/src/algokit_utils/transactions/transaction_composer.py +++ b/src/algokit_utils/transactions/transaction_composer.py @@ -1,47 +1,52 @@ from __future__ import annotations -import logging import math from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Union import algosdk import algosdk.atomic_transaction_composer +import algosdk.v2client.models from algosdk.atomic_transaction_composer import ( AtomicTransactionComposer, TransactionSigner, TransactionWithSigner, ) from algosdk.error import AlgodHTTPError -from algosdk.transaction import OnComplete, Transaction +from algosdk.transaction import OnComplete from algosdk.v2client.algod import AlgodClient -from deprecated import deprecated +from typing_extensions import deprecated from algokit_utils._debugging import simulate_and_persist_response, simulate_response from algokit_utils.applications.app_manager import AppManager from algokit_utils.config import config +from algokit_utils.models.transaction import SendParams +from algokit_utils.transactions.models import TransactionWrapper +from algokit_utils.transactions.utils import encode_lease, populate_app_call_resources if TYPE_CHECKING: from collections.abc import Callable from algosdk.abi import Method - from algosdk.box_reference import BoxReference from algosdk.v2client.algod import AlgodClient + from algosdk.v2client.models import SimulateTraceConfig + from algokit_utils.applications.app_manager import BoxReference from algokit_utils.models.abi import ABIValue from algokit_utils.models.amount import AlgoAmount from algokit_utils.transactions.models import Arc2TransactionNote -logger = logging.getLogger(__name__) +logger = config.logger -@dataclass(frozen=True) + +@dataclass(kw_only=True, frozen=True) class SenderParam: sender: str -@dataclass(frozen=True) -class CommonTxnParams: +@dataclass(kw_only=True, frozen=True) +class CommonTxnParams(SendParams): """ Common transaction parameters. @@ -72,14 +77,10 @@ class CommonTxnParams: last_valid_round: int | None = None -@dataclass(frozen=True) -class _RequiredPaymentParams: - receiver: str - amount: AlgoAmount - - -@dataclass(frozen=True) -class PaymentParams(CommonTxnParams, _RequiredPaymentParams): +@dataclass(kw_only=True, frozen=True) +class PaymentParams( + CommonTxnParams, +): """ Payment transaction parameters. @@ -88,21 +89,14 @@ class PaymentParams(CommonTxnParams, _RequiredPaymentParams): :param close_remainder_to: If given, close the sender account and send the remaining balance to this address. """ + receiver: str + amount: AlgoAmount close_remainder_to: str | None = None -@dataclass(frozen=True) -class _RequiredAssetCreateParams: - total: int - asset_name: str - unit_name: str - url: str - - -@dataclass(frozen=True) +@dataclass(kw_only=True, frozen=True) class AssetCreateParams( CommonTxnParams, - _RequiredAssetCreateParams, ): """ Asset creation parameters. @@ -123,6 +117,10 @@ class AssetCreateParams( :param metadata_hash: Hash of the metadata contained in the metadata URL. """ + total: int + asset_name: str | None = None + unit_name: str | None = None + url: str | None = None decimals: int | None = None default_frozen: bool | None = None manager: str | None = None @@ -132,15 +130,9 @@ class AssetCreateParams( metadata_hash: bytes | None = None -@dataclass(frozen=True) -class _RequiredAssetConfigParams: - asset_id: int - - -@dataclass(frozen=True) +@dataclass(kw_only=True, frozen=True) class AssetConfigParams( CommonTxnParams, - _RequiredAssetConfigParams, ): """ Asset configuration parameters. @@ -155,23 +147,16 @@ class AssetConfigParams( Clawback will be permanently disabled if undefined or an empty string. """ + asset_id: int manager: str | None = None reserve: str | None = None freeze: str | None = None clawback: str | None = None -@dataclass(frozen=True) -class _RequiredAssetFreezeParams: - asset_id: int - account: str - frozen: bool - - -@dataclass(frozen=True) +@dataclass(kw_only=True, frozen=True) class AssetFreezeParams( CommonTxnParams, - _RequiredAssetFreezeParams, ): """ Asset freeze parameters. @@ -181,16 +166,14 @@ class AssetFreezeParams( :param frozen: Whether the assets in the account should be frozen. """ - -@dataclass(frozen=True) -class _RequiredAssetDestroyParams: asset_id: int + account: str + frozen: bool -@dataclass(frozen=True) +@dataclass(kw_only=True, frozen=True) class AssetDestroyParams( CommonTxnParams, - _RequiredAssetDestroyParams, ): """ Asset destruction parameters. @@ -198,20 +181,12 @@ class AssetDestroyParams( :param asset_id: ID of the asset. """ - -@dataclass(frozen=True) -class _RequiredOnlineKeyRegistrationParams: - vote_key: str - selection_key: str - vote_first: int - vote_last: int - vote_key_dilution: int + asset_id: int -@dataclass(frozen=True) +@dataclass(kw_only=True, frozen=True) class OnlineKeyRegistrationParams( CommonTxnParams, - _RequiredOnlineKeyRegistrationParams, ): """ Online key registration parameters. @@ -227,20 +202,17 @@ class OnlineKeyRegistrationParams( :param state_proof_key: The 64 byte state proof public key commitment. """ + vote_key: str + selection_key: str + vote_first: int + vote_last: int + vote_key_dilution: int state_proof_key: bytes | None = None -@dataclass(frozen=True) -class _RequiredAssetTransferParams: - asset_id: int - amount: int - receiver: str - - -@dataclass(frozen=True) +@dataclass(kw_only=True, frozen=True) class AssetTransferParams( CommonTxnParams, - _RequiredAssetTransferParams, ): """ Asset transfer parameters. @@ -252,19 +224,16 @@ class AssetTransferParams( :param close_asset_to: The account to close the asset to. """ + asset_id: int + amount: int + receiver: str clawback_target: str | None = None close_asset_to: str | None = None -@dataclass(frozen=True) -class _RequiredAssetOptInParams: - asset_id: int - - -@dataclass(frozen=True) +@dataclass(kw_only=True, frozen=True) class AssetOptInParams( CommonTxnParams, - _RequiredAssetOptInParams, ): """ Asset opt-in parameters. @@ -272,24 +241,22 @@ class AssetOptInParams( :param asset_id: ID of the asset. """ - -@dataclass(frozen=True) -class _RequiredAssetOptOutParams: asset_id: int - creator: str -@dataclass(frozen=True) +@dataclass(kw_only=True, frozen=True) class AssetOptOutParams( CommonTxnParams, - _RequiredAssetOptOutParams, ): """ Asset opt-out parameters. """ + asset_id: int + creator: str -@dataclass(frozen=True) + +@dataclass(kw_only=True, frozen=True) class AppCallParams(CommonTxnParams, SenderParam): """ Application call parameters. @@ -320,14 +287,8 @@ class AppCallParams(CommonTxnParams, SenderParam): box_references: list[BoxReference] | None = None -@dataclass(frozen=True) -class _RequiredAppCreateParams: - approval_program: str | bytes - clear_state_program: str | bytes - - -@dataclass(frozen=True) -class AppCreateParams(CommonTxnParams, SenderParam, _RequiredAppCreateParams): +@dataclass(kw_only=True, frozen=True) +class AppCreateParams(CommonTxnParams, SenderParam): """ Application create parameters. @@ -345,6 +306,8 @@ class AppCreateParams(CommonTxnParams, SenderParam, _RequiredAppCreateParams): :param extra_program_pages: Number of extra pages required for the programs """ + approval_program: str | bytes + clear_state_program: str | bytes schema: dict[str, int] | None = None on_complete: OnComplete | None = None args: list[bytes] | None = None @@ -355,15 +318,8 @@ class AppCreateParams(CommonTxnParams, SenderParam, _RequiredAppCreateParams): extra_program_pages: int | None = None -@dataclass(frozen=True) -class _RequiredAppUpdateParams: - app_id: int - approval_program: str | bytes - clear_state_program: str | bytes - - -@dataclass(frozen=True) -class AppUpdateParams(CommonTxnParams, SenderParam, _RequiredAppUpdateParams): +@dataclass(kw_only=True, frozen=True) +class AppUpdateParams(CommonTxnParams, SenderParam): """ Application update parameters. @@ -374,6 +330,9 @@ class AppUpdateParams(CommonTxnParams, SenderParam, _RequiredAppUpdateParams): teal (bytes) """ + app_id: int + approval_program: str | bytes + clear_state_program: str | bytes args: list[bytes] | None = None account_references: list[str] | None = None app_references: list[int] | None = None @@ -382,16 +341,10 @@ class AppUpdateParams(CommonTxnParams, SenderParam, _RequiredAppUpdateParams): on_complete: OnComplete | None = None -@dataclass(frozen=True) -class _RequiredAppDeleteParams: - app_id: int - - -@dataclass(frozen=True) +@dataclass(kw_only=True, frozen=True) class AppDeleteParams( CommonTxnParams, SenderParam, - _RequiredAppDeleteParams, ): """ Application delete parameters. @@ -400,19 +353,20 @@ class AppDeleteParams( """ app_id: int + args: list[bytes] | None = None + account_references: list[str] | None = None + app_references: list[int] | None = None + asset_references: list[int] | None = None + box_references: list[BoxReference] | None = None on_complete: OnComplete = OnComplete.DeleteApplicationOC -@dataclass(frozen=True) -class _RequiredMethodCallParams: - app_id: int - method: Method - - -@dataclass(frozen=True) -class AppMethodCall(CommonTxnParams, SenderParam, _RequiredMethodCallParams): +@dataclass(kw_only=True, frozen=True) +class AppMethodCall(CommonTxnParams, SenderParam): """Base class for ABI method calls.""" + app_id: int + method: Method args: list | None = None account_references: list[str] | None = None app_references: list[int] | None = None @@ -420,14 +374,8 @@ class AppMethodCall(CommonTxnParams, SenderParam, _RequiredMethodCallParams): box_references: list[BoxReference] | None = None -@dataclass(frozen=True) -class _RequiredAppMethodCallParams: - app_id: int - method: Method - - -@dataclass(frozen=True) -class AppMethodCallParams(CommonTxnParams, SenderParam, _RequiredAppMethodCallParams): +@dataclass(kw_only=True, frozen=True) +class AppMethodCallParams(CommonTxnParams, SenderParam): """ Method call parameters. @@ -437,6 +385,8 @@ class AppMethodCallParams(CommonTxnParams, SenderParam, _RequiredAppMethodCallPa :param on_complete: The OnComplete action (cannot be UpdateApplication or ClearState) """ + app_id: int + method: Method args: list[bytes] | None = None on_complete: OnComplete | None = None account_references: list[str] | None = None @@ -445,7 +395,7 @@ class AppMethodCallParams(CommonTxnParams, SenderParam, _RequiredAppMethodCallPa box_references: list[BoxReference] | None = None -@dataclass(frozen=True) +@dataclass(kw_only=True, frozen=True) class AppCallMethodCall(AppMethodCall): """Parameters for a regular ABI method call. @@ -464,14 +414,8 @@ class AppCallMethodCall(AppMethodCall): on_complete: OnComplete | None = None -@dataclass(frozen=True) -class _RequiredAppCreateMethodCallParams: - approval_program: str | bytes - clear_state_program: str | bytes - - -@dataclass(frozen=True) -class AppCreateMethodCall(AppMethodCall, _RequiredAppCreateMethodCallParams): +@dataclass(kw_only=True, frozen=True) +class AppCreateMethodCall(AppMethodCall): """Parameters for an ABI method call that creates an application. :param approval_program: The program to execute for all OnCompletes other than ClearState @@ -481,20 +425,15 @@ class AppCreateMethodCall(AppMethodCall, _RequiredAppCreateMethodCallParams): :param extra_program_pages: Number of extra pages required for the programs """ + approval_program: str | bytes + clear_state_program: str | bytes schema: dict[str, int] | None = None on_complete: OnComplete | None = None extra_program_pages: int | None = None -@dataclass(frozen=True) -class _RequiredAppUpdateMethodCallParams: - app_id: int - approval_program: str | bytes - clear_state_program: str | bytes - - -@dataclass(frozen=True) -class AppUpdateMethodCall(AppMethodCall, _RequiredAppUpdateMethodCallParams): +@dataclass(kw_only=True, frozen=True) +class AppUpdateMethodCall(AppMethodCall): """Parameters for an ABI method call that updates an application. :param app_id: ID of the application @@ -502,10 +441,13 @@ class AppUpdateMethodCall(AppMethodCall, _RequiredAppUpdateMethodCallParams): :param clear_state_program: The program to execute for ClearState OnComplete """ + app_id: int + approval_program: str | bytes + clear_state_program: str | bytes on_complete: OnComplete = OnComplete.UpdateApplicationOC -@dataclass(frozen=True) +@dataclass(kw_only=True, frozen=True) class AppDeleteMethodCall(AppMethodCall): """Parameters for an ABI method call that deletes an application. @@ -548,7 +490,7 @@ class AppDeleteMethodCall(AppMethodCall): ] -@dataclass +@dataclass(frozen=True) class BuiltTransactions: """ Set of transactions built by TransactionComposer. @@ -574,26 +516,27 @@ class TransactionComposerBuildResult: class SendAtomicTransactionComposerResults: """Results from sending an AtomicTransactionComposer transaction group""" - group_id: str | None + group_id: str """The group ID if this was a transaction group""" confirmations: list[algosdk.v2client.algod.AlgodResponseType] """The confirmation info for each transaction""" tx_ids: list[str] """The transaction IDs that were sent""" - transactions: list[Transaction] + transactions: list[TransactionWrapper] """The transactions that were sent""" - returns: list[Any] + returns: list[Any] | list[algosdk.atomic_transaction_composer.ABIResult] """The ABI return values from any ABI method calls""" + simulate_response: dict[str, Any] | None = None -def send_atomic_transaction_composer( # noqa: C901, PLR0912, PLR0913 +def send_atomic_transaction_composer( # noqa: C901, PLR0912 atc: AtomicTransactionComposer, algod: AlgodClient, *, max_rounds_to_wait: int | None = 5, skip_waiting: bool = False, - suppress_log: bool = False, - populate_resources: bool | None = None, # TODO: implement/clarify # noqa: ARG001 + suppress_log: bool | None = None, + populate_resources: bool | None = None, # TODO: implement/clarify ) -> SendAtomicTransactionComposerResults: """Send an AtomicTransactionComposer transaction group @@ -615,6 +558,13 @@ def send_atomic_transaction_composer( # noqa: C901, PLR0912, PLR0913 try: # Build transactions transactions_with_signer = atc.build_group() + + if populate_resources or ( + config.populate_app_call_resource + and any(isinstance(t.txn, algosdk.transaction.ApplicationCallTxn) for t in transactions_with_signer) + ): + atc = populate_app_call_resources(atc, algod) + transactions_to_send = [t.txn for t in transactions_with_signer] # Get group ID if multiple transactions @@ -652,11 +602,11 @@ def send_atomic_transaction_composer( # noqa: C901, PLR0912, PLR0913 # Return results return SendAtomicTransactionComposerResults( - group_id=group_id, + group_id=group_id or "", confirmations=confirmations or [], tx_ids=[t.get_txid() for t in transactions_to_send], - transactions=transactions_to_send, - returns=[r.return_value for r in result.abi_results], + transactions=[TransactionWrapper(t) for t in transactions_to_send], + returns=result.abi_results, ) except AlgodHTTPError as e: @@ -720,7 +670,7 @@ class TransactionComposer: NULL_SIGNER: TransactionSigner = algosdk.atomic_transaction_composer.EmptySigner() - def __init__( # noqa: PLR0913 + def __init__( self, algod: AlgodClient, get_signer: Callable[[str], TransactionSigner], @@ -884,7 +834,7 @@ def build_transactions(self) -> BuiltTransactions: return BuiltTransactions(transactions=transactions, method_calls=method_calls, signers=signers) - @deprecated(reason="Use send() instead", version="3.0.0") + @deprecated("Use send() instead") def execute( self, *, @@ -898,8 +848,8 @@ def send( self, *, max_rounds_to_wait: int | None = None, - suppress_log: bool = False, - populate_app_call_resources: bool = False, + suppress_log: bool | None = None, + populate_app_call_resources: bool | None = None, ) -> SendAtomicTransactionComposerResults: group = self.build().transactions @@ -920,18 +870,78 @@ def send( except algosdk.error.AlgodHTTPError as e: raise Exception(f"Transaction failed: {e}") from e - def simulate(self) -> algosdk.atomic_transaction_composer.SimulateAtomicTransactionResponse: + def simulate( + self, + allow_more_logs: bool | None = None, + allow_empty_signatures: bool | None = None, + allow_unnamed_resources: bool | None = None, + extra_opcode_budget: int | None = None, + exec_trace_config: SimulateTraceConfig | None = None, + round: int | None = None, # noqa: A002 TODO: revisit + skip_signatures: int | None = None, + fix_signers: bool | None = None, + ) -> SendAtomicTransactionComposerResults: + atc = AtomicTransactionComposer() if skip_signatures else self.atc + + if skip_signatures: + allow_empty_signatures = True + fix_signers = True + transactions = self.build_transactions() + for txn in transactions.transactions: + atc.add_transaction(TransactionWithSigner(txn=txn, signer=TransactionComposer.NULL_SIGNER)) + atc.method_dict = transactions.method_calls + else: + self.build() + if config.debug and config.project_root and config.trace_all: - return simulate_and_persist_response( - self.atc, + response = simulate_and_persist_response( + atc, config.project_root, self.algod, config.trace_buffer_size_mb, + allow_more_logs, + allow_empty_signatures, + allow_unnamed_resources, + extra_opcode_budget, + exec_trace_config, + round, + skip_signatures, + fix_signers, + ) + + return SendAtomicTransactionComposerResults( + confirmations=[], # TODO: extract confirmations, + transactions=[TransactionWrapper(txn.txn) for txn in atc.txn_list], + tx_ids=response.tx_ids, + group_id=atc.txn_list[-1].txn.group or "", + simulate_response=response.simulate_response, + returns=response.abi_results, ) - return simulate_response( - self.atc, + response = simulate_response( + atc, self.algod, + allow_more_logs, + allow_empty_signatures, + allow_unnamed_resources, + extra_opcode_budget, + exec_trace_config, + round, + skip_signatures, + fix_signers, + ) + + confirmation_results = response.simulate_response.get("txn-groups", [{"txn-results": [{"txn-result": {}}]}])[0][ + "txn-results" + ] + + return SendAtomicTransactionComposerResults( + confirmations=[txn["txn-result"] for txn in confirmation_results], + transactions=[TransactionWrapper(txn.txn) for txn in atc.txn_list], + tx_ids=response.tx_ids, + group_id=atc.txn_list[-1].txn.group or "", + simulate_response=response.simulate_response, + returns=response.abi_results, ) @staticmethod @@ -966,7 +976,7 @@ def _common_txn_build_step( suggested_params: algosdk.transaction.SuggestedParams, ) -> algosdk.transaction.Transaction: if params.lease: - txn.lease = params.lease + txn.lease = encode_lease(params.lease) if params.rekey_to: txn.rekey_to = params.rekey_to if params.note: @@ -1002,53 +1012,55 @@ def _build_method_call( # noqa: C901, PLR0912 arg_offset = 0 if params.args: - for i, arg in enumerate(params.args): + for _, arg in enumerate(params.args): if self._is_abi_value(arg): method_args.append(arg) continue - if algosdk.abi.is_abi_transaction_type(params.method.args[i + arg_offset].type): - match arg: - case ( - AppCreateMethodCall() - | AppCallMethodCall() - | AppUpdateMethodCall() - | AppDeleteMethodCall() - ): - temp_txn_with_signers = self._build_method_call(arg, suggested_params) - method_args.extend(temp_txn_with_signers) - arg_offset += len(temp_txn_with_signers) - 1 - continue - case AppCallParams(): - txn = self._build_app_call(arg, suggested_params) - case PaymentParams(): - txn = self._build_payment(arg, suggested_params) - case AssetOptInParams(): - txn = self._build_asset_transfer( - AssetTransferParams(**arg.__dict__, receiver=arg.sender, amount=0), suggested_params - ) - case AssetCreateParams(): - txn = self._build_asset_create(arg, suggested_params) - case AssetConfigParams(): - txn = self._build_asset_config(arg, suggested_params) - case AssetDestroyParams(): - txn = self._build_asset_destroy(arg, suggested_params) - case AssetFreezeParams(): - txn = self._build_asset_freeze(arg, suggested_params) - case AssetTransferParams(): - txn = self._build_asset_transfer(arg, suggested_params) - case OnlineKeyRegistrationParams(): - txn = self._build_key_reg(arg, suggested_params) - case _: - raise ValueError(f"Unsupported method arg transaction type: {arg!s}") + if isinstance(arg, TransactionWithSigner): + method_args.append(arg) + continue + if isinstance(arg, algosdk.transaction.Transaction): + # Wrap in TransactionWithSigner method_args.append( - TransactionWithSigner(txn=txn, signer=params.signer or self.get_signer(params.sender)) + TransactionWithSigner(txn=arg, signer=params.signer or self.get_signer(params.sender)) ) - continue + match arg: + case AppCreateMethodCall() | AppCallMethodCall() | AppUpdateMethodCall() | AppDeleteMethodCall(): + temp_txn_with_signers = self._build_method_call(arg, suggested_params) + method_args.extend(temp_txn_with_signers) + arg_offset += len(temp_txn_with_signers) - 1 + continue + case AppCallParams(): + txn = self._build_app_call(arg, suggested_params) + case PaymentParams(): + txn = self._build_payment(arg, suggested_params) + case AssetOptInParams(): + txn = self._build_asset_transfer( + AssetTransferParams(**arg.__dict__, receiver=arg.sender, amount=0), suggested_params + ) + case AssetCreateParams(): + txn = self._build_asset_create(arg, suggested_params) + case AssetConfigParams(): + txn = self._build_asset_config(arg, suggested_params) + case AssetDestroyParams(): + txn = self._build_asset_destroy(arg, suggested_params) + case AssetFreezeParams(): + txn = self._build_asset_freeze(arg, suggested_params) + case AssetTransferParams(): + txn = self._build_asset_transfer(arg, suggested_params) + case OnlineKeyRegistrationParams(): + txn = self._build_key_reg(arg, suggested_params) + case _: + raise ValueError(f"Unsupported method arg transaction type: {arg!s}") + + method_args.append( + TransactionWithSigner(txn=txn, signer=params.signer or self.get_signer(params.sender)) + ) - raise ValueError(f"Unsupported method arg: {arg!s}") + continue method_atc = AtomicTransactionComposer() @@ -1059,10 +1071,18 @@ def _build_method_call( # noqa: C901, PLR0912 sp=suggested_params, signer=params.signer or self.get_signer(params.sender), method_args=method_args, - on_complete=algosdk.transaction.OnComplete.NoOpOC, + on_complete=params.on_complete or algosdk.transaction.OnComplete.NoOpOC, note=params.note, lease=params.lease, - boxes=[(ref.app_index, ref.name) for ref in params.box_references] if params.box_references else None, + boxes=[AppManager.get_box_reference(ref) for ref in params.box_references] + if params.box_references + else None, + foreign_apps=params.app_references, + foreign_assets=params.asset_references, + accounts=params.account_references, + approval_program=params.approval_program if hasattr(params, "approval_program") else None, # type: ignore[arg-type] + clear_program=params.clear_state_program if hasattr(params, "clear_state_program") else None, # type: ignore[arg-type] + rekey_to=params.rekey_to, ) return self._build_atc(method_atc) @@ -1088,13 +1108,13 @@ def _build_asset_create( sp=suggested_params, total=params.total, default_frozen=params.default_frozen or False, - unit_name=params.unit_name, - asset_name=params.asset_name, + unit_name=params.unit_name or "", + asset_name=params.asset_name or "", manager=params.manager, reserve=params.reserve, freeze=params.freeze, clawback=params.clawback, - url=params.url, + url=params.url or "", metadata_hash=params.metadata_hash, decimals=params.decimals or 0, ) @@ -1103,10 +1123,10 @@ def _build_asset_create( def _build_app_call( self, - params: AppCallParams | AppUpdateParams | AppCreateParams, + params: AppCallParams | AppUpdateParams | AppCreateParams | AppDeleteParams, suggested_params: algosdk.transaction.SuggestedParams, ) -> algosdk.transaction.Transaction: - app_id = params.app_id if isinstance(params, AppCallParams | AppUpdateMethodCall) else None + app_id = getattr(params, "app_id", 0) approval_program = None clear_program = None @@ -1148,12 +1168,12 @@ def _build_app_call( txn = algosdk.transaction.ApplicationCreateTxn( **sdk_params, global_schema=algosdk.transaction.StateSchema( - num_uints=params.schema.get("global_uints", 0), - num_byte_slices=params.schema.get("global_byte_slices", 0), + num_uints=params.schema.get("global_ints", 0), + num_byte_slices=params.schema.get("global_bytes", 0), ), local_schema=algosdk.transaction.StateSchema( - num_uints=params.schema.get("local_uints", 0), - num_byte_slices=params.schema.get("local_byte_slices", 0), + num_uints=params.schema.get("local_ints", 0), + num_byte_slices=params.schema.get("local_bytes", 0), ), extra_pages=params.extra_program_pages or math.floor((approval_program_len + clear_program_len) / algosdk.constants.APP_PAGE_MAX_SIZE) @@ -1239,7 +1259,7 @@ def _build_key_reg( return self._common_txn_build_step(params, txn, suggested_params) def _is_abi_value(self, x: bool | float | str | bytes | list | TxnParams) -> bool: - if isinstance(x, list): + if isinstance(x, list | tuple): return len(x) == 0 or all(self._is_abi_value(item) for item in x) return isinstance(x, bool | int | float | str | bytes) @@ -1254,6 +1274,9 @@ def _build_txn( # noqa: C901, PLR0912, PLR0911 return [txn] case AtomicTransactionComposer(): return self._build_atc(txn) + case algosdk.transaction.Transaction(): + signer = self.get_signer(txn.sender) + return [TransactionWithSigner(txn=txn, signer=signer)] case AppCreateMethodCall() | AppCallMethodCall() | AppUpdateMethodCall() | AppDeleteMethodCall(): return self._build_method_call(txn, suggested_params) @@ -1266,7 +1289,7 @@ def _build_txn( # noqa: C901, PLR0912, PLR0911 case AssetCreateParams(): asset_create = self._build_asset_create(txn, suggested_params) return [TransactionWithSigner(txn=asset_create, signer=signer)] - case AppCallParams() | AppUpdateParams() | AppCreateParams(): + case AppCallParams() | AppUpdateParams() | AppCreateParams() | AppDeleteParams(): app_call = self._build_app_call(txn, suggested_params) return [TransactionWithSigner(txn=app_call, signer=signer)] case AssetConfigParams(): diff --git a/src/algokit_utils/transactions/transaction_sender.py b/src/algokit_utils/transactions/transaction_sender.py index 3050dcb7..831100d2 100644 --- a/src/algokit_utils/transactions/transaction_sender.py +++ b/src/algokit_utils/transactions/transaction_sender.py @@ -1,16 +1,16 @@ from collections.abc import Callable from dataclasses import dataclass -from logging import getLogger from typing import Any, TypedDict, TypeVar import algosdk import algosdk.atomic_transaction_composer -from algosdk.atomic_transaction_composer import AtomicTransactionResponse +from algosdk.atomic_transaction_composer import ABIResult, AtomicTransactionResponse from algosdk.transaction import Transaction from algokit_utils.applications.app_manager import AppManager from algokit_utils.assets.asset_manager import AssetManager -from algokit_utils.models.abi import ABIValue +from algokit_utils.config import config +from algokit_utils.transactions.models import TransactionWrapper from algokit_utils.transactions.transaction_composer import ( AppCallMethodCall, AppCallParams, @@ -33,48 +33,45 @@ TxnParams, ) -logger = getLogger(__name__) +logger = config.logger -@dataclass +@dataclass(frozen=True, kw_only=True) class SendSingleTransactionResult: - tx_id: str # Single transaction ID (last from txIds array) - transaction: Transaction # Last transaction + transaction: TransactionWrapper # Last transaction confirmation: algosdk.v2client.algod.AlgodResponseType # Last confirmation # Fields from SendAtomicTransactionComposerResults group_id: str + tx_id: str | None = None tx_ids: list[str] # Full array of transaction IDs - transactions: list[Transaction] + transactions: list[TransactionWrapper] confirmations: list[algosdk.v2client.algod.AlgodResponseType] returns: list[algosdk.atomic_transaction_composer.ABIResult] | None = None - # Fields from AssetCreateParams - asset_id: int | None = None +@dataclass(frozen=True, kw_only=True) +class SendSingleAssetCreateTransactionResult(SendSingleTransactionResult): + asset_id: int -@dataclass + +@dataclass(frozen=True) class SendAppTransactionResult(SendSingleTransactionResult): - return_value: ABIValue | None = None + return_value: ABIResult | None = None -@dataclass +@dataclass(frozen=True) class SendAppUpdateTransactionResult(SendAppTransactionResult): compiled_approval: Any | None = None compiled_clear: Any | None = None -@dataclass -class _RequiredSendAppTransactionResult: +@dataclass(frozen=True, kw_only=True) +class SendAppCreateTransactionResult(SendAppUpdateTransactionResult): app_id: int app_address: str -@dataclass -class SendAppCreateTransactionResult(SendAppUpdateTransactionResult, _RequiredSendAppTransactionResult): - pass - - class LogConfig(TypedDict, total=False): pre_log: Callable[[TxnParams, Transaction], str] post_log: Callable[[TxnParams, AtomicTransactionResponse], str] @@ -116,11 +113,14 @@ def send_transaction(params: T) -> SendSingleTransactionResult: logger.debug(pre_log(params, transaction)) raw_result = composer.send() + raw_result_dict = raw_result.__dict__.copy() + raw_result_dict["transactions"] = raw_result.transactions + del raw_result_dict["simulate_response"] result = SendSingleTransactionResult( - **raw_result.__dict__, + **raw_result_dict, confirmation=raw_result.confirmations[-1], - transaction=raw_result.transactions[-1], + transaction=raw_result_dict["transactions"][-1], tx_id=raw_result.tx_ids[-1], ) @@ -210,7 +210,7 @@ def payment(self, params: PaymentParams) -> SendSingleTransactionResult: ), )(params) - def asset_create(self, params: AssetCreateParams) -> SendSingleTransactionResult: + def asset_create(self, params: AssetCreateParams) -> SendSingleAssetCreateTransactionResult: """Create a new Algorand Standard Asset.""" result = self._send( lambda c: c.add_asset_create, @@ -223,11 +223,10 @@ def asset_create(self, params: AssetCreateParams) -> SendSingleTransactionResult ), )(params) - result = SendSingleTransactionResult( + return SendSingleAssetCreateTransactionResult( **result.__dict__, + asset_id=int(result.confirmation["asset-index"]), # type: ignore[call-overload] ) - result.asset_id = int(result.confirmation["asset-index"]) # type: ignore[call-overload] - return result def asset_config(self, params: AssetConfigParams) -> SendSingleTransactionResult: """Configure an existing Algorand Standard Asset.""" diff --git a/src/algokit_utils/transactions/utils.py b/src/algokit_utils/transactions/utils.py new file mode 100644 index 00000000..216db262 --- /dev/null +++ b/src/algokit_utils/transactions/utils.py @@ -0,0 +1,302 @@ +from typing import Any, cast + +from algosdk import logic, transaction +from algosdk.atomic_transaction_composer import AtomicTransactionComposer, EmptySigner, TransactionWithSigner +from algosdk.box_reference import BoxReference +from algosdk.error import AtomicTransactionComposerError +from algosdk.v2client.algod import AlgodClient +from algosdk.v2client.models import SimulateRequest + +# Constants +MAX_APP_CALL_ACCOUNT_REFERENCES = 4 +MAX_APP_CALL_FOREIGN_REFERENCES = 8 + + +def populate_app_call_resources(atc: AtomicTransactionComposer, algod: AlgodClient) -> AtomicTransactionComposer: # noqa: C901, PLR0915, PLR0912 + """ + Populate application call resources based on simulation results. + """ + # Get unnamed resources from simulation + unnamed_resources = get_unnamed_app_call_resources_accessed(atc, algod) + group = atc.build_group() + + # Process transaction-level resources + for i, txn_resources in enumerate(unnamed_resources["txns"]): + if not txn_resources or not isinstance(group[i].txn, transaction.ApplicationCallTxn): + continue + + # Validate no unexpected resources + if txn_resources.get("boxes") or txn_resources.get("extraBoxRefs"): + raise ValueError("Unexpected boxes at the transaction level") + if txn_resources.get("appLocals"): + raise ValueError("Unexpected app local at the transaction level") + if txn_resources.get("assetHoldings"): + raise ValueError("Unexpected asset holding at the transaction level") + + # Update application call fields + app_txn = cast(transaction.ApplicationCallTxn, group[i].txn) + accounts = list(getattr(app_txn, "accounts", []) or []) + foreign_apps = list(getattr(app_txn, "foreign_apps", []) or []) + foreign_assets = list(getattr(app_txn, "foreign_assets", []) or []) + boxes = list(getattr(app_txn, "boxes", []) or []) + + # Add new resources + accounts.extend(txn_resources.get("accounts", [])) + foreign_apps.extend(txn_resources.get("apps", [])) + foreign_assets.extend(txn_resources.get("assets", [])) + boxes.extend(txn_resources.get("boxes", [])) + + # Validate limits + if len(accounts) > MAX_APP_CALL_ACCOUNT_REFERENCES: + raise ValueError( + f"Account reference limit of {MAX_APP_CALL_ACCOUNT_REFERENCES} exceeded in transaction {i}" + ) + + total_refs = len(accounts) + len(foreign_assets) + len(foreign_apps) + len(boxes) + if total_refs > MAX_APP_CALL_FOREIGN_REFERENCES: + raise ValueError( + f"Resource reference limit of {MAX_APP_CALL_FOREIGN_REFERENCES} exceeded in transaction {i}" + ) + + # Update transaction + app_txn.accounts = accounts + app_txn.foreign_apps = foreign_apps + app_txn.foreign_assets = foreign_assets + app_txn.boxes = boxes + + def populate_group_resource( # noqa: C901, PLR0915 + txns: list[TransactionWithSigner], reference: str | BoxReference | dict[str, Any] | int, ref_type: str + ) -> None: + """Helper function to populate group-level resources""" + + def is_appl_below_limit(t: TransactionWithSigner) -> bool: + if not isinstance(t.txn, transaction.ApplicationCallTxn): + return False + + app_txn = t.txn + accounts = len(app_txn.accounts or []) + assets = len(app_txn.foreign_assets or []) + apps = len(app_txn.foreign_apps or []) + boxes = len(app_txn.boxes or []) + + return accounts + assets + apps + boxes < MAX_APP_CALL_FOREIGN_REFERENCES + + # Handle asset holding and app local references + if ref_type in ("assetHolding", "appLocal"): + ref_dict = cast(dict[str, Any], reference) + account = ref_dict["account"] + + # Try to find transaction with account already available + txn_idx = next( + ( + i + for i, t in enumerate(txns) + if is_appl_below_limit(t) + and isinstance(t.txn, transaction.ApplicationCallTxn) + and ( + account in (getattr(t.txn, "accounts", []) or []) + or account + in ( + logic.get_application_address(app_id) + for app_id in (getattr(t.txn, "foreign_apps", []) or []) + ) + or any(account in str(v) for v in t.txn.__dict__.values()) + ) + ), + -1, + ) + + if txn_idx >= 0: + app_txn = cast(transaction.ApplicationCallTxn, txns[txn_idx].txn) + if ref_type == "assetHolding": + asset_id = ref_dict["asset"] + app_txn.foreign_assets = [*list(getattr(app_txn, "foreign_assets", []) or []), asset_id] + else: + app_id = ref_dict["app"] + app_txn.foreign_apps = [*list(getattr(app_txn, "foreign_apps", []) or []), app_id] + return + + # Find available transaction for the resource + txn_idx = next( + ( + i + for i, t in enumerate(txns) + if is_appl_below_limit(t) + and isinstance(t.txn, transaction.ApplicationCallTxn) + and ( + len(getattr(t.txn, "accounts", []) or []) < MAX_APP_CALL_ACCOUNT_REFERENCES + if ref_type == "account" + else True + ) + ), + -1, + ) + + if txn_idx == -1: + raise ValueError("No more transactions below reference limit. Add another app call to the group.") + + app_txn = cast(transaction.ApplicationCallTxn, txns[txn_idx].txn) + + # Add resource based on type + if ref_type == "account": + accounts = list(getattr(app_txn, "accounts", []) or []) + accounts.append(cast(str, reference)) + app_txn.accounts = accounts + elif ref_type == "app": + foreign_apps = list(getattr(app_txn, "foreign_apps", []) or []) + foreign_apps.append(int(cast(str | int, reference))) + app_txn.foreign_apps = foreign_apps + elif ref_type == "box": + box_ref = cast(BoxReference, reference) + boxes = list(getattr(app_txn, "boxes", []) or []) + boxes.append(box_ref) + app_txn.boxes = boxes + if box_ref.app_index != 0: + foreign_apps = list(getattr(app_txn, "foreign_apps", []) or []) + foreign_apps.append(box_ref.app_index) + app_txn.foreign_apps = foreign_apps + elif ref_type == "asset": + foreign_assets = list(getattr(app_txn, "foreign_assets", []) or []) + foreign_assets.append(int(cast(str | int, reference))) + app_txn.foreign_assets = foreign_assets + elif ref_type == "assetHolding": + ref_dict = cast(dict[str, Any], reference) + foreign_assets = list(getattr(app_txn, "foreign_assets", []) or []) + foreign_assets.append(ref_dict["asset"]) + app_txn.foreign_assets = foreign_assets + accounts = list(getattr(app_txn, "accounts", []) or []) + accounts.append(ref_dict["account"]) + app_txn.accounts = accounts + elif ref_type == "appLocal": + ref_dict = cast(dict[str, Any], reference) + foreign_apps = list(getattr(app_txn, "foreign_apps", []) or []) + foreign_apps.append(ref_dict["app"]) + app_txn.foreign_apps = foreign_apps + accounts = list(getattr(app_txn, "accounts", []) or []) + accounts.append(ref_dict["account"]) + app_txn.accounts = accounts + + # Process group-level resources + group_resources = unnamed_resources["group"] + if group_resources: + # Handle cross-reference resources first + for app_local in group_resources.get("appLocals", []): + populate_group_resource(group, app_local, "appLocal") + # Remove processed resources + if "accounts" in group_resources: + group_resources["accounts"] = [ + acc for acc in group_resources["accounts"] if acc != app_local["account"] + ] + if "apps" in group_resources: + group_resources["apps"] = [app for app in group_resources["apps"] if int(app) != int(app_local["app"])] + + for asset_holding in group_resources.get("assetHoldings", []): + populate_group_resource(group, asset_holding, "assetHolding") + # Remove processed resources + if "accounts" in group_resources: + group_resources["accounts"] = [ + acc for acc in group_resources["accounts"] if acc != asset_holding["account"] + ] + if "assets" in group_resources: + group_resources["assets"] = [ + asset for asset in group_resources["assets"] if int(asset) != int(asset_holding["asset"]) + ] + + # Handle remaining resources + for account in group_resources.get("accounts", []): + populate_group_resource(group, account, "account") + + for box in group_resources.get("boxes", []): + populate_group_resource(group, box, "box") + if "apps" in group_resources: + group_resources["apps"] = [app for app in group_resources["apps"] if int(app) != int(box.app_index)] + + for asset in group_resources.get("assets", []): + populate_group_resource(group, asset, "asset") + + for app in group_resources.get("apps", []): + populate_group_resource(group, app, "app") + + # Handle extra box references + extra_box_refs = group_resources.get("extraBoxRefs", 0) + for _ in range(extra_box_refs): + empty_box = BoxReference(0, b"") + populate_group_resource(group, empty_box, "box") + + # Create new ATC with updated transactions + new_atc = AtomicTransactionComposer() + for txn_with_signer in group: + txn_with_signer.txn.group = None + new_atc.add_transaction(txn_with_signer) + + # Copy method calls + new_atc.method_dict = atc.method_dict.copy() + + return new_atc + + +def get_unnamed_app_call_resources_accessed(atc: AtomicTransactionComposer, algod: AlgodClient) -> dict[str, Any]: + """Get unnamed resources accessed by application calls in an atomic transaction group.""" + # Create simulation request with required flags + simulate_request = SimulateRequest( + txn_groups=[], allow_unnamed_resources=True, allow_empty_signatures=True, extra_opcode_budget=0 + ) + + # Create empty signer + null_signer = EmptySigner() + + # Clone the ATC and replace signers + empty_signer_atc = atc.clone() + for txn in empty_signer_atc.txn_list: + txn.signer = null_signer + + # Run simulation + result = empty_signer_atc.simulate(algod, simulate_request) + + # Get first group response + group_response = result.simulate_response["txn-groups"][0] + + # Check for simulation failure + if group_response.get("failure-message"): + failed_at = group_response.get("failed-at", [0])[0] + raise AtomicTransactionComposerError( + f"Error during resource population simulation in transaction {failed_at}: " + f"{group_response['failure-message']}" + ) + + # Return resources accessed at group and transaction level + return { + "group": group_response.get("unnamed-resources-accessed", {}), + "txns": [txn.get("unnamed-resources-accessed", {}) for txn in group_response.get("txn-results", [])], + } + + +MAX_LEASE_LENGTH = 32 + + +def encode_lease(lease: str | bytes | None) -> bytes | None: + if lease is None: + return None + elif isinstance(lease, bytes): + if not (1 <= len(lease) <= MAX_LEASE_LENGTH): + raise ValueError( + f"Received invalid lease; expected something with length between 1 and {MAX_LEASE_LENGTH}, " + f"but received bytes with length {len(lease)}" + ) + if len(lease) == MAX_LEASE_LENGTH: + return lease + lease32 = bytearray(32) + lease32[: len(lease)] = lease + return bytes(lease32) + elif isinstance(lease, str): + encoded = lease.encode("utf-8") + if not (1 <= len(encoded) <= MAX_LEASE_LENGTH): + raise ValueError( + f"Received invalid lease; expected something with length between 1 and {MAX_LEASE_LENGTH}, " + f"but received '{lease}' with length {len(lease)}" + ) + lease32 = bytearray(MAX_LEASE_LENGTH) + lease32[: len(encoded)] = encoded + return bytes(lease32) + else: + raise TypeError(f"Unknown lease type received of {type(lease)}") diff --git a/tests/accounts/__init__.py b/tests/accounts/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/accounts/test_account_manager.py b/tests/accounts/test_account_manager.py new file mode 100644 index 00000000..ec56a007 --- /dev/null +++ b/tests/accounts/test_account_manager.py @@ -0,0 +1,108 @@ +import algosdk +import pytest + +from algokit_utils import Account +from algokit_utils.clients.algorand_client import AlgorandClient +from algokit_utils.models.amount import AlgoAmount +from tests.conftest import get_unique_name + + +@pytest.fixture +def algorand() -> AlgorandClient: + return AlgorandClient.default_local_net() + + +@pytest.fixture +def funded_account(algorand: AlgorandClient) -> Account: + new_account = algorand.account.random() + dispenser = algorand.account.localnet_dispenser() + algorand.account.ensure_funded( + new_account, dispenser, AlgoAmount.from_algos(100), min_funding_increment=AlgoAmount.from_algos(1) + ) + algorand.set_signer(sender=new_account.address, signer=new_account.signer) + return new_account + + +def test_new_account_is_retrieved_and_funded(algorand: AlgorandClient) -> None: + # Act + account_name = get_unique_name() + account = algorand.account.from_environment(account_name) + + # Assert + account_info = algorand.account.get_information(account.address) + assert account_info["amount"] > 0 + + +def test_same_account_is_subsequently_retrieved(algorand: AlgorandClient) -> None: + # Arrange + account_name = get_unique_name() + + # Act + account1 = algorand.account.from_environment(account_name) + account2 = algorand.account.from_environment(account_name) + + # Assert - accounts should be different objects but with same underlying keys + assert account1 is not account2 + assert account1.address == account2.address + assert account1.private_key == account2.private_key + + +def test_environment_is_used_in_preference_to_kmd(algorand: AlgorandClient, monkeypatch: pytest.MonkeyPatch) -> None: + # Arrange + account_name = get_unique_name() + account1 = algorand.account.from_environment(account_name) + + # Set up environment variable for second account + env_account_name = "TEST_ACCOUNT" + monkeypatch.setenv(f"{env_account_name}_MNEMONIC", algosdk.mnemonic.from_private_key(account1.private_key)) + + # Act + account2 = algorand.account.from_environment(env_account_name) + + # Assert - accounts should be different objects but with same underlying keys + assert account1 is not account2 + assert account1.address == account2.address + assert account1.private_key == account2.private_key + + +def test_random_account_creation(algorand: AlgorandClient) -> None: + # Act + account = algorand.account.random() + + # Assert + assert account.address + assert account.private_key + assert len(account.public_key) == 32 + + +def test_ensure_funded_from_environment(algorand: AlgorandClient) -> None: + # Arrange + account = algorand.account.random() + min_balance = AlgoAmount.from_algos(1) + + # Act + result = algorand.account.ensure_funded_from_environment( + account_to_fund=account.address, + min_spending_balance=min_balance, + ) + + # Assert + assert result is not None + assert result.amount_funded is not None + account_info = algorand.account.get_information(account.address) + assert account_info["amount"] >= min_balance.micro_algos + + +def test_get_account_information(algorand: AlgorandClient) -> None: + # Arrange + account = algorand.account.random() + + # Act + info = algorand.account.get_information(account.address) + + # Assert + assert isinstance(info, dict) + assert "amount" in info + assert "min-balance" in info + assert "address" in info + assert info["address"] == account.address diff --git a/tests/applications/test_app_client.py b/tests/applications/test_app_client.py new file mode 100644 index 00000000..c246924a --- /dev/null +++ b/tests/applications/test_app_client.py @@ -0,0 +1,733 @@ +import base64 +import json +import random +from pathlib import Path +from typing import Any + +import algosdk +import pytest +from algosdk.atomic_transaction_composer import TransactionSigner, TransactionWithSigner + +from algokit_utils._legacy_v2.application_specification import ApplicationSpecification +from algokit_utils.applications.app_client import ( + AppClient, + AppClientMethodCallWithSendParams, + AppClientParams, + FundAppAccountParams, +) +from algokit_utils.applications.app_manager import AppManager, BoxReference +from algokit_utils.applications.utils import arc32_to_arc56, get_arc56_method +from algokit_utils.clients.algorand_client import AlgorandClient +from algokit_utils.errors.logic_error import LogicError +from algokit_utils.models.abi import ABIType +from algokit_utils.models.account import Account +from algokit_utils.models.amount import AlgoAmount +from algokit_utils.models.application import Arc56Contract +from algokit_utils.transactions.transaction_composer import AppCreateParams, PaymentParams + + +@pytest.fixture +def algorand() -> AlgorandClient: + return AlgorandClient.default_local_net() + + +@pytest.fixture +def funded_account(algorand: AlgorandClient) -> Account: + new_account = algorand.account.random() + dispenser = algorand.account.localnet_dispenser() + algorand.account.ensure_funded( + new_account, dispenser, AlgoAmount.from_algos(100), min_funding_increment=AlgoAmount.from_algos(1) + ) + algorand.set_signer(sender=new_account.address, signer=new_account.signer) + return new_account + + +@pytest.fixture +def raw_hello_world_arc32_app_spec() -> str: + raw_json_spec = Path(__file__).parent.parent / "artifacts" / "hello_world" / "arc32_app_spec.json" + return raw_json_spec.read_text() + + +@pytest.fixture +def hello_world_arc32_app_spec() -> ApplicationSpecification: + raw_json_spec = Path(__file__).parent.parent / "artifacts" / "hello_world" / "arc32_app_spec.json" + return ApplicationSpecification.from_json(raw_json_spec.read_text()) + + +@pytest.fixture +def hello_world_arc32_app_id( + algorand: AlgorandClient, funded_account: Account, hello_world_arc32_app_spec: ApplicationSpecification +) -> int: + global_schema = hello_world_arc32_app_spec.global_state_schema + local_schema = hello_world_arc32_app_spec.local_state_schema + response = algorand.send.app_create( + AppCreateParams( + sender=funded_account.address, + approval_program=hello_world_arc32_app_spec.approval_program, + clear_state_program=hello_world_arc32_app_spec.clear_program, + schema={ + "global_ints": int(global_schema.num_uints) if global_schema.num_uints else 0, + "global_bytes": int(global_schema.num_byte_slices) if global_schema.num_byte_slices else 0, + "local_ints": int(local_schema.num_uints) if local_schema.num_uints else 0, + "local_bytes": int(local_schema.num_byte_slices) if local_schema.num_byte_slices else 0, + }, + ) + ) + return response.app_id + + +@pytest.fixture +def raw_testing_app_arc32_app_spec() -> str: + raw_json_spec = Path(__file__).parent.parent / "artifacts" / "testing_app" / "arc32_app_spec.json" + return raw_json_spec.read_text() + + +@pytest.fixture +def testing_app_arc32_app_spec() -> ApplicationSpecification: + raw_json_spec = Path(__file__).parent.parent / "artifacts" / "testing_app" / "arc32_app_spec.json" + return ApplicationSpecification.from_json(raw_json_spec.read_text()) + + +@pytest.fixture +def testing_app_arc32_app_id( + algorand: AlgorandClient, funded_account: Account, testing_app_arc32_app_spec: ApplicationSpecification +) -> int: + global_schema = testing_app_arc32_app_spec.global_state_schema + local_schema = testing_app_arc32_app_spec.local_state_schema + approval = AppManager.replace_template_variables( + testing_app_arc32_app_spec.approval_program, + { + "VALUE": 1, + "UPDATABLE": 0, + "DELETABLE": 0, + }, + ) + response = algorand.send.app_create( + AppCreateParams( + sender=funded_account.address, + approval_program=approval, + clear_state_program=testing_app_arc32_app_spec.clear_program, + schema={ + "global_bytes": int(global_schema.num_byte_slices) if global_schema.num_byte_slices else 0, + "global_ints": int(global_schema.num_uints) if global_schema.num_uints else 0, + "local_bytes": int(local_schema.num_byte_slices) if local_schema.num_byte_slices else 0, + "local_ints": int(local_schema.num_uints) if local_schema.num_uints else 0, + }, + ) + ) + return response.app_id + + +@pytest.fixture +def test_app_client( + algorand: AlgorandClient, + funded_account: Account, + testing_app_arc32_app_spec: ApplicationSpecification, + testing_app_arc32_app_id: int, +) -> AppClient: + return AppClient( + AppClientParams( + default_sender=funded_account.address, + default_signer=funded_account.signer, + app_id=testing_app_arc32_app_id, + algorand=algorand, + app_spec=testing_app_arc32_app_spec, + ) + ) + + +@pytest.fixture +def test_app_client_with_sourcemaps( + algorand: AlgorandClient, + funded_account: Account, + testing_app_arc32_app_spec: ApplicationSpecification, + testing_app_arc32_app_id: int, +) -> AppClient: + sourcemaps = json.loads( + (Path(__file__).parent.parent / "artifacts" / "testing_app" / "sources.teal.map.json").read_text() + ) + return AppClient( + AppClientParams( + default_sender=funded_account.address, + default_signer=funded_account.signer, + app_id=testing_app_arc32_app_id, + algorand=algorand, + approval_source_map=algosdk.source_map.SourceMap(sourcemaps["approvalSourceMap"]), + clear_source_map=algosdk.source_map.SourceMap(sourcemaps["clearSourceMap"]), + app_spec=testing_app_arc32_app_spec, + ) + ) + + +@pytest.fixture +def testing_app_puya_arc32_app_spec() -> ApplicationSpecification: + raw_json_spec = Path(__file__).parent.parent / "artifacts" / "testing_app_puya" / "arc32_app_spec.json" + return ApplicationSpecification.from_json(raw_json_spec.read_text()) + + +@pytest.fixture +def testing_app_puya_arc32_app_id( + algorand: AlgorandClient, funded_account: Account, testing_app_puya_arc32_app_spec: ApplicationSpecification +) -> int: + global_schema = testing_app_puya_arc32_app_spec.global_state_schema + local_schema = testing_app_puya_arc32_app_spec.local_state_schema + + response = algorand.send.app_create( + AppCreateParams( + sender=funded_account.address, + approval_program=testing_app_puya_arc32_app_spec.approval_program, + clear_state_program=testing_app_puya_arc32_app_spec.clear_program, + schema={ + "global_bytes": int(global_schema.num_byte_slices) if global_schema.num_byte_slices else 0, + "global_ints": int(global_schema.num_uints) if global_schema.num_uints else 0, + "local_bytes": int(local_schema.num_byte_slices) if local_schema.num_byte_slices else 0, + "local_ints": int(local_schema.num_uints) if local_schema.num_uints else 0, + }, + ) + ) + return response.app_id + + +@pytest.fixture +def test_app_client_puya( + algorand: AlgorandClient, + funded_account: Account, + testing_app_puya_arc32_app_spec: ApplicationSpecification, + testing_app_puya_arc32_app_id: int, +) -> AppClient: + return AppClient( + AppClientParams( + default_sender=funded_account.address, + default_signer=funded_account.signer, + app_id=testing_app_puya_arc32_app_id, + algorand=algorand, + app_spec=testing_app_puya_arc32_app_spec, + ) + ) + + +# TODO: add variations around arc 56 contracts too + + +def test_clone_overriding_default_sender_and_inheriting_app_name( + algorand: AlgorandClient, + funded_account: Account, + hello_world_arc32_app_spec: ApplicationSpecification, + hello_world_arc32_app_id: int, +) -> None: + app_client = AppClient( + AppClientParams( + default_sender=funded_account.address, + default_signer=funded_account.signer, + app_id=hello_world_arc32_app_id, + algorand=algorand, + app_spec=hello_world_arc32_app_spec, + ) + ) + + cloned_default_sender = "ABC" * 55 + cloned_app_client = app_client.clone(default_sender=cloned_default_sender) + + assert app_client.app_name == "HelloWorld" + assert cloned_app_client.app_id == app_client.app_id + assert cloned_app_client.app_name == app_client.app_name + assert cloned_app_client._default_sender == cloned_default_sender # noqa: SLF001 + assert app_client._default_sender == funded_account.address # noqa: SLF001 + + +def test_clone_overriding_app_name( + algorand: AlgorandClient, + funded_account: Account, + hello_world_arc32_app_spec: ApplicationSpecification, + hello_world_arc32_app_id: int, +) -> None: + app_client = AppClient( + AppClientParams( + default_sender=funded_account.address, + default_signer=funded_account.signer, + app_id=hello_world_arc32_app_id, + algorand=algorand, + app_spec=hello_world_arc32_app_spec, + ) + ) + + cloned_app_name = "George CLONEy" + cloned_app_client = app_client.clone(app_name=cloned_app_name) + assert app_client.app_name == hello_world_arc32_app_spec.contract.name == "HelloWorld" + assert cloned_app_client.app_name == cloned_app_name + + +def test_clone_inheriting_app_name_based_on_default_handling( + algorand: AlgorandClient, + funded_account: Account, + hello_world_arc32_app_spec: ApplicationSpecification, + hello_world_arc32_app_id: int, +) -> None: + app_client = AppClient( + AppClientParams( + default_sender=funded_account.address, + default_signer=funded_account.signer, + app_id=hello_world_arc32_app_id, + algorand=algorand, + app_spec=hello_world_arc32_app_spec, + ) + ) + + cloned_app_name = None + cloned_app_client = app_client.clone(app_name=cloned_app_name) + assert cloned_app_client.app_name == hello_world_arc32_app_spec.contract.name == app_client.app_name + + +def test_normalise_app_spec( + raw_hello_world_arc32_app_spec: str, + hello_world_arc32_app_spec: ApplicationSpecification, +) -> None: + normalized_app_spec_from_arc32 = AppClient.normalise_app_spec(hello_world_arc32_app_spec) + assert isinstance(normalized_app_spec_from_arc32, Arc56Contract) + + normalize_app_spec_from_raw_arc32 = AppClient.normalise_app_spec(raw_hello_world_arc32_app_spec) + assert isinstance(normalize_app_spec_from_raw_arc32, Arc56Contract) + + +def test_resolve_from_network( + algorand: AlgorandClient, + hello_world_arc32_app_id: int, + hello_world_arc32_app_spec: ApplicationSpecification, +) -> None: + arc56_app_spec = arc32_to_arc56(hello_world_arc32_app_spec) + arc56_app_spec.networks = {"localnet": {"app_id": hello_world_arc32_app_id}} + app_client = AppClient.from_network( + algorand=algorand, + app_spec=arc56_app_spec, + ) + + assert app_client + + +def test_construct_transaction_with_boxes(test_app_client: AppClient) -> None: + call = test_app_client.create_transaction.call( + AppClientMethodCallWithSendParams( + method="call_abi", + args=["test"], + box_references=[BoxReference(app_id=0, name=b"1")], + ) + ) + + assert isinstance(call.transactions[0], algosdk.transaction.ApplicationCallTxn) + assert call.transactions[0].boxes == [BoxReference(app_id=0, name=b"1")] + + # Test with string box reference + call2 = test_app_client.create_transaction.call( + AppClientMethodCallWithSendParams( + method="call_abi", + args=["test"], + box_references=["1"], + ) + ) + + assert isinstance(call2.transactions[0], algosdk.transaction.ApplicationCallTxn) + assert call2.transactions[0].boxes == [BoxReference(app_id=0, name=b"1")] + + +def test_construct_transaction_with_abi_encoding_including_transaction( + algorand: AlgorandClient, funded_account: Account, test_app_client: AppClient +) -> None: + # Create a payment transaction with random amount + amount = AlgoAmount.from_micro_algos(random.randint(1, 10000)) + payment_txn = algorand.create_transaction.payment( + PaymentParams( + sender=funded_account.address, + receiver=funded_account.address, + amount=amount, + ) + ) + + # Call the ABI method with the payment transaction + result = test_app_client.send.call( + AppClientMethodCallWithSendParams( + method="call_abi_txn", + args=[payment_txn, "test"], + ) + ) + + assert result.confirmation + assert len(result.transactions) == 2 + return_value = AppManager.get_abi_return( + result.confirmation, get_arc56_method("call_abi_txn", test_app_client.app_spec) + ) + expected_return = f"Sent {amount.micro_algos}. test" + assert result.return_value + assert result.return_value.return_value == expected_return + assert return_value + assert return_value.return_value == result.return_value.return_value + + +def test_sign_all_transactions_in_group_with_abi_call_with_transaction_arg( + algorand: AlgorandClient, test_app_client: AppClient, funded_account: Account +) -> None: + # Create a payment transaction with a random amount + amount = AlgoAmount.from_micro_algos(random.randint(1, 10000)) + txn = algorand.create_transaction.payment( + PaymentParams( + sender=funded_account.address, + receiver=funded_account.address, + amount=amount, + ) + ) + + called_indexes = [] + original_signer = algorand.account.get_signer(funded_account.address) + + class IndexCapturingSigner(TransactionSigner): + def sign_transactions( + self, txn_group: list[algosdk.transaction.Transaction], indexes: list[int] + ) -> list[algosdk.transaction.GenericSignedTransaction]: + called_indexes.extend(indexes) + return original_signer.sign_transactions(txn_group, indexes) + + test_app_client.send.call( + AppClientMethodCallWithSendParams( + method="call_abi_txn", + args=[txn, "test"], + sender=funded_account.address, + signer=IndexCapturingSigner(), + ) + ) + + assert called_indexes == [0, 1] + + +def test_sign_transaction_in_group_with_different_signer_if_provided( + algorand: AlgorandClient, test_app_client: AppClient, funded_account: Account +) -> None: + # Generate a new account + test_account = algorand.account.random() + algorand.account.ensure_funded( + account_to_fund=test_account, + dispenser_account=funded_account, + min_spending_balance=AlgoAmount.from_algos(10), + min_funding_increment=AlgoAmount.from_algos(1), + ) + + # Fund the account with 1 Algo + txn = algorand.create_transaction.payment( + PaymentParams( + sender=test_account.address, + receiver=test_account.address, + amount=AlgoAmount.from_algos(random.randint(1, 5)), + ) + ) + + # Call method with transaction and signer + test_app_client.send.call( + AppClientMethodCallWithSendParams( + method="call_abi_txn", + args=[TransactionWithSigner(txn=txn, signer=test_account.signer), "test"], + ) + ) + + +def test_construct_transaction_with_abi_encoding_including_foreign_references_not_in_signature( + algorand: AlgorandClient, test_app_client: AppClient, funded_account: Account +) -> None: + test_account = algorand.account.random() + algorand.account.ensure_funded( + account_to_fund=test_account, + dispenser_account=funded_account, + min_spending_balance=AlgoAmount.from_algos(10), + min_funding_increment=AlgoAmount.from_algos(1), + ) + + result = test_app_client.send.call( + AppClientMethodCallWithSendParams( + method="call_abi_foreign_refs", + app_references=[345], + account_references=[test_account.address], + asset_references=[567], + ) + ) + + # Assuming the method returns a string matching the format below + expected_return = AppManager.get_abi_return( + result.confirmations[0], + get_arc56_method("call_abi_foreign_refs", test_app_client.app_spec), + ) + assert result.return_value + assert "App: 345, Asset: 567, Account: " in result.return_value.return_value + assert expected_return + assert expected_return.return_value == result.return_value.return_value + + +def test_retrieve_state(test_app_client: AppClient, funded_account: Account) -> None: + # Test global state + test_app_client.send.call( + AppClientMethodCallWithSendParams(method="set_global", args=[1, 2, "asdf", bytes([1, 2, 3, 4])]) + ) + global_state = test_app_client.get_global_state() + + assert "int1" in global_state + assert "int2" in global_state + assert "bytes1" in global_state + assert "bytes2" in global_state + assert hasattr(global_state["bytes2"], "value_raw") + assert sorted(global_state.keys()) == ["bytes1", "bytes2", "int1", "int2", "value"] + assert global_state["int1"].value == 1 + assert global_state["int2"].value == 2 + assert global_state["bytes1"].value == "asdf" + assert global_state["bytes2"].value_raw == bytes([1, 2, 3, 4]) + + # Test local state + test_app_client.send.opt_in(AppClientMethodCallWithSendParams(method="opt_in")) + test_app_client.send.call( + AppClientMethodCallWithSendParams(method="set_local", args=[1, 2, "asdf", bytes([1, 2, 3, 4])]) + ) + local_state = test_app_client.get_local_state(funded_account.address) + + assert "local_int1" in local_state + assert "local_int2" in local_state + assert "local_bytes1" in local_state + assert "local_bytes2" in local_state + assert sorted(local_state.keys()) == ["local_bytes1", "local_bytes2", "local_int1", "local_int2"] + assert local_state["local_int1"].value == 1 + assert local_state["local_int2"].value == 2 + assert local_state["local_bytes1"].value == "asdf" + assert local_state["local_bytes2"].value_raw == bytes([1, 2, 3, 4]) + + # Test box storage + box_name1 = bytes([0, 0, 0, 1]) + box_name1_base64 = base64.b64encode(box_name1).decode() + box_name2 = bytes([0, 0, 0, 2]) + box_name2_base64 = base64.b64encode(box_name2).decode() + + test_app_client.fund_app_account(params=FundAppAccountParams(amount=AlgoAmount.from_algos(1))) + + test_app_client.send.call( + AppClientMethodCallWithSendParams( + method="set_box", + args=[box_name1, "value1"], + box_references=[box_name1], + ) + ) + test_app_client.send.call( + AppClientMethodCallWithSendParams( + method="set_box", + args=[box_name2, "value2"], + box_references=[box_name2], + ) + ) + + box_values = test_app_client.get_box_values() + box1_value = test_app_client.get_box_value(box_name1) + + assert sorted(b.name.name_base64 for b in box_values) == sorted([box_name1_base64, box_name2_base64]) + box1 = next(b for b in box_values if b.name.name_base64 == box_name1_base64) + assert box1.value == base64.b64encode(bytes("value1", "utf-8")) + assert box1_value == box1.value + + box2 = next(b for b in box_values if b.name.name_base64 == box_name2_base64) + assert box2.value == base64.b64encode(bytes("value2", "utf-8")) + + # Legacy contract strips ABI prefix; manually encoded ABI string after + # passing algosdk's atc results in \x00\n\x00\n1234524352. + expected_value_decoded = "1234524352" + expected_value = "\x00\n" + expected_value_decoded + test_app_client.send.call( + AppClientMethodCallWithSendParams( + method="set_box", + args=[box_name1, expected_value], + box_references=[box_name1], + ) + ) + + boxes = test_app_client.get_box_values_from_abi_type( + ABIType.from_string("string"), + lambda n: n.name_base64 == box_name1_base64, + ) + box1_abi_value = test_app_client.get_box_value_from_abi_type(box_name1, ABIType.from_string("string")) + + assert len(boxes) == 1 + assert boxes[0].value == expected_value_decoded + assert box1_abi_value == expected_value_decoded + + +@pytest.mark.parametrize( + ("box_name", "box_value", "value_type", "expected_value"), + [ + ( + "name1", + b"test_bytes", # Updated to match Bytes type + "byte[]", + [116, 101, 115, 116, 95, 98, 121, 116, 101, 115], + ), + ( + "name2", + "test_string", + "string", + "test_string", + ), + ( + "name3", # Updated to use string key + 123, + "uint32", + 123, + ), + ( + "name4", # Updated to use string key + 2**256, # Large number within uint512 range + "uint512", + 2**256, + ), + ( + "name5", # Updated to use string key + [1, 2, 3, 4], + "byte[4]", + [1, 2, 3, 4], + ), + ], +) +def test_box_methods_with_manually_encoded_abi_args( + test_app_client_puya: AppClient, + box_name: Any, # noqa: ANN401 + box_value: Any, # noqa: ANN401 + value_type: str, + expected_value: Any, # noqa: ANN401 +) -> None: + # Fund the app account + box_prefix = b"box_bytes" + + test_app_client_puya.fund_app_account(params=FundAppAccountParams(amount=AlgoAmount.from_algos(1))) + + # Encode the box reference + box_identifier = box_prefix + ABIType.from_string("string").encode(box_name) + + # Call the method to set the box value + test_app_client_puya.send.call( + AppClientMethodCallWithSendParams( + method="set_box_bytes", + args=[box_name, ABIType.from_string(value_type).encode(box_value)], + box_references=[box_identifier], + ) + ) + + # Get and verify the box value + box_abi_value = test_app_client_puya.get_box_value_from_abi_type(box_identifier, ABIType.from_string(value_type)) + + # Convert the retrieved value to match expected type if needed + assert box_abi_value == expected_value + + +@pytest.mark.parametrize( + ("box_prefix_str", "method", "arg_value", "value_type"), + [ + ("box_str", "set_box_str", "string", "string"), + ("box_int", "set_box_int", 123, "uint32"), + ("box_int512", "set_box_int512", 2**256, "uint512"), + ("box_static", "set_box_static", [1, 2, 3, 4], "byte[4]"), + ("", "set_struct", ("box1", 123), "(string,uint64)"), + ], +) +def test_box_methods_with_arc4_returns_parametrized( + test_app_client_puya: AppClient, + box_prefix_str: str, + method: str, + arg_value: Any, # noqa: ANN401 + value_type: str, +) -> None: + """ + Test setting and retrieving box values with different data types and box prefixes. + + Args: + test_app_client_puya (AppClient): The AppClient instance for testing. + box_prefix_str (str): The string prefix for the box. + method (str): The method name to call for setting the box. + arg_value (Any): The value to set in the box. + value_type (str): The ABI type of the value. + """ + # Encode the box prefix + box_prefix = box_prefix_str.encode() + + # Fund the app account with 1 Algo + test_app_client_puya.fund_app_account(params=FundAppAccountParams(amount=AlgoAmount.from_algos(1))) + + # Encode the box name "box1" using ABIType "string" + box_name_encoded = ABIType.from_string("string").encode("box1") + box_reference = box_prefix + box_name_encoded + + # Send the transaction to set the box value + test_app_client_puya.send.call( + AppClientMethodCallWithSendParams( + method=method, + args=["box1", arg_value], + box_references=[box_reference], + ) + ) + + # Encode the expected value using the specified ABI type + value_encoded = ABIType.from_string(value_type).encode(arg_value) + expected_value = base64.b64encode(value_encoded) + + # Retrieve the actual box value + actual_box_value = test_app_client_puya.get_box_value(box_reference) + + # Assert that the actual box value matches the expected value + assert actual_box_value == expected_value + + if method == "set_struct": + abi_decoded_boxes = test_app_client_puya.get_box_values_from_abi_type( + ABIType.from_string("(string,uint64)"), + lambda n: n.name_base64 == base64.b64encode(box_prefix + box_name_encoded).decode(), + ) + assert len(abi_decoded_boxes) == 1 + assert abi_decoded_boxes[0].value == arg_value + + +# TODO: see if needs moving into app factory tests file +def test_abi_with_default_arg_method( + algorand: AlgorandClient, + funded_account: Account, + testing_app_arc32_app_id: int, + testing_app_arc32_app_spec: ApplicationSpecification, +) -> None: + arc56_app_spec = arc32_to_arc56(testing_app_arc32_app_spec) + arc56_app_spec.networks = {"localnet": {"app_id": testing_app_arc32_app_id}} + app_client = AppClient.from_network( + algorand=algorand, + app_spec=arc56_app_spec, + default_sender=funded_account.address, + default_signer=funded_account.signer, + ) + # app_client.send. + app_client.send.opt_in(AppClientMethodCallWithSendParams(method="opt_in")) + app_client.send.call( + AppClientMethodCallWithSendParams( + method="set_local", + args=[1, 2, "banana", [1, 2, 3, 4]], + ) + ) + + method_signature = "default_value_from_local_state(string)string" + defined_value = "defined value" + + # Test with defined value + defined_value_result = app_client.send.call( + AppClientMethodCallWithSendParams(method=method_signature, args=[defined_value]) + ) + + assert defined_value_result.return_value + assert defined_value_result.return_value.return_value == "Local state, defined value" + + # Test with default value + default_value_result = app_client.send.call(AppClientMethodCallWithSendParams(method=method_signature, args=[None])) + assert default_value_result.return_value + assert default_value_result.return_value.return_value == "Local state, banana" + + +def test_exposing_logic_error(test_app_client_with_sourcemaps: AppClient) -> None: + with pytest.raises(LogicError) as exc_info: + test_app_client_with_sourcemaps.send.call(AppClientMethodCallWithSendParams(method="error")) + + error = exc_info.value + assert error.pc == 885 + assert "assert failed pc=885" in str(error) + assert len(error.transaction_id) == 52 + assert error.line_no == 469 diff --git a/tests/applications/test_app_factory.py b/tests/applications/test_app_factory.py new file mode 100644 index 00000000..8cf9e75a --- /dev/null +++ b/tests/applications/test_app_factory.py @@ -0,0 +1,488 @@ +from pathlib import Path + +import algosdk +import pytest +from algosdk.logic import get_application_address +from algosdk.transaction import OnComplete + +from algokit_utils import OnSchemaBreak, OnUpdate, OperationPerformed +from algokit_utils.applications.app_client import ( + AppClient, + AppClientMethodCallParams, + AppClientMethodCallWithCompilationAndSendParams, + AppClientMethodCallWithSendParams, + AppClientParams, +) +from algokit_utils.applications.app_factory import ( + AppFactory, + AppFactoryCreateMethodCallParams, + AppFactoryCreateWithSendParams, +) +from algokit_utils.clients.algorand_client import AlgorandClient +from algokit_utils.errors.logic_error import LogicError +from algokit_utils.models.account import Account +from algokit_utils.models.amount import AlgoAmount +from algokit_utils.transactions.transaction_composer import PaymentParams + + +@pytest.fixture +def algorand() -> AlgorandClient: + return AlgorandClient.default_local_net() + + +@pytest.fixture +def funded_account(algorand: AlgorandClient) -> Account: + new_account = algorand.account.random() + dispenser = algorand.account.localnet_dispenser() + algorand.account.ensure_funded( + new_account, dispenser, AlgoAmount.from_algos(100), min_funding_increment=AlgoAmount.from_algos(1) + ) + algorand.set_signer(sender=new_account.address, signer=new_account.signer) + return new_account + + +@pytest.fixture +def app_spec() -> str: + return (Path(__file__).parent.parent / "artifacts" / "testing_app" / "arc32_app_spec.json").read_text() + + +@pytest.fixture +def factory(algorand: AlgorandClient, funded_account: Account, app_spec: str) -> AppFactory: + """Create AppFactory fixture""" + return algorand.client.get_app_factory(app_spec=app_spec, default_sender=funded_account.address) + + +@pytest.fixture +def arc56_factory( + algorand: AlgorandClient, + funded_account: Account, +) -> AppFactory: + """Create AppFactory fixture""" + arc56_raw_spec = ( + Path(__file__).parent.parent / "artifacts" / "testing_app_arc56" / "arc56_app_spec.json" + ).read_text() + return algorand.client.get_app_factory(app_spec=arc56_raw_spec, default_sender=funded_account.address) + + +def test_create_app(factory: AppFactory) -> None: + """Test creating an app using the factory""" + app_client, result = factory.send.bare.create( + params=AppFactoryCreateWithSendParams( + deploy_time_params={ + # It should strip off the TMPL_ + "TMPL_UPDATABLE": 0, + "DELETABLE": 0, + "VALUE": 1, + } + ) + ) + + assert app_client.app_id > 0 + assert app_client.app_address == get_application_address(app_client.app_id) + assert isinstance(result.confirmation, dict) + assert result.confirmation.get("application-index", 0) == app_client.app_id + assert result.compiled_approval is not None + assert result.compiled_clear is not None + + +def test_create_app_with_constructor_deploy_time_params(algorand: AlgorandClient, app_spec: str) -> None: + """Test creating an app using the factory with constructor deploy time params""" + random_account = algorand.account.random() + dispenser_account = algorand.account.localnet_dispenser() + algorand.account.ensure_funded( + account_to_fund=random_account, + dispenser_account=dispenser_account.address, + min_spending_balance=AlgoAmount.from_algo(10), + min_funding_increment=AlgoAmount.from_algo(1), + ) + + factory = algorand.client.get_app_factory( + app_spec=app_spec, + default_sender=random_account.address, + deploy_time_params={ + # It should strip off the TMPL_ + "TMPL_UPDATABLE": 0, + "DELETABLE": 0, + "VALUE": 1, + }, + ) + + app_client, result = factory.send.bare.create() + + assert result.app_id > 0 + assert app_client.app_id == result.app_id + + +def test_create_app_with_oncomplete_overload(factory: AppFactory) -> None: + app_client, result = factory.send.bare.create( + params=AppFactoryCreateWithSendParams( + on_complete=OnComplete.OptInOC, + updatable=True, + deletable=True, + deploy_time_params={ + "VALUE": 1, + }, + ) + ) + + assert result.transaction.application_call + assert result.transaction.application_call.on_complete == OnComplete.OptInOC + assert app_client.app_id > 0 + assert app_client.app_address == get_application_address(app_client.app_id) + assert isinstance(result.confirmation, dict) + assert result.confirmation.get("application-index", 0) == app_client.app_id + + +def test_deploy_when_immutable_and_permanent(factory: AppFactory) -> None: + factory.deploy( + deletable=False, + updatable=False, + on_schema_break=OnSchemaBreak.Fail, + on_update=OnUpdate.Fail, + deploy_time_params={ + "VALUE": 1, + }, + ) + + +def test_deploy_app_create(factory: AppFactory) -> None: + app_client, result = factory.deploy( + deploy_time_params={ + "VALUE": 1, + }, + ) + + assert result.operation_performed == OperationPerformed.Create + assert result.app_id > 0 + assert app_client.app_id == result.app_id == result.confirmation["application-index"] # type: ignore[call-overload] + assert app_client.app_address == get_application_address(app_client.app_id) + + +def test_deploy_app_create_abi(factory: AppFactory) -> None: + app_client, result = factory.deploy( + deploy_time_params={ + "VALUE": 1, + }, + create_params=AppClientMethodCallParams(method="create_abi", args=["arg_io"]), + ) + + assert result.operation_performed == OperationPerformed.Create + assert result.app_id > 0 + assert app_client.app_id == result.app_id == result.confirmation["application-index"] # type: ignore[call-overload] + assert app_client.app_address == get_application_address(app_client.app_id) + + +def test_deploy_app_update(factory: AppFactory) -> None: + _, created_app = factory.deploy( + deploy_time_params={ + "VALUE": 1, + }, + updatable=True, + ) + + _, updated_app = factory.deploy( + deploy_time_params={ + "VALUE": 2, + }, + on_update=OnUpdate.UpdateApp, + ) + + assert updated_app.operation_performed == OperationPerformed.Update + assert created_app.app_id == updated_app.app_id + assert created_app.app_address == updated_app.app_address + assert created_app.confirmation + assert created_app.updatable + assert created_app.updatable == updated_app.updatable + assert created_app.updated_round != updated_app.updated_round + assert created_app.created_round == updated_app.created_round + assert updated_app.updated_round == updated_app.confirmation["confirmed-round"] # type: ignore[call-overload] + + +def test_deploy_app_update_abi(factory: AppFactory) -> None: + _, created_app = factory.deploy( + deploy_time_params={ + "VALUE": 1, + }, + updatable=True, + ) + + _, updated_app = factory.deploy( + deploy_time_params={ + "VALUE": 2, + }, + on_update=OnUpdate.UpdateApp, + update_params=AppClientMethodCallParams(method="update_abi", args=["args_io"]), + ) + + assert updated_app.operation_performed == OperationPerformed.Update + assert updated_app.app_id == created_app.app_id + assert updated_app.app_address == created_app.app_address + assert updated_app.confirmation is not None + assert updated_app.created_round == created_app.created_round + assert updated_app.updated_round != updated_app.created_round + assert updated_app.updated_round == updated_app.confirmation["confirmed-round"] # type: ignore[call-overload] + assert updated_app.transaction.application_call + assert updated_app.transaction.application_call.on_complete == OnComplete.UpdateApplicationOC + assert updated_app.return_value == "args_io" + + +def test_deploy_app_replace(factory: AppFactory) -> None: + _, created_app = factory.deploy( + deploy_time_params={ + "VALUE": 1, + }, + deletable=True, + ) + + _, replaced_app = factory.deploy( + deploy_time_params={ + "VALUE": 2, + }, + on_update=OnUpdate.ReplaceApp, + ) + + assert replaced_app.operation_performed == OperationPerformed.Replace + assert replaced_app.app_id > created_app.app_id + assert replaced_app.app_address == algosdk.logic.get_application_address(replaced_app.app_id) + assert replaced_app.confirmation is not None + assert replaced_app.delete_result is not None + assert replaced_app.delete_result.confirmation is not None + assert len(replaced_app.transactions) == 2 + assert replaced_app.delete_result.transaction.application_call + assert replaced_app.delete_result.transaction.application_call.index == created_app.app_id + assert replaced_app.delete_result.transaction.application_call.on_complete == OnComplete.DeleteApplicationOC + + +def test_deploy_app_replace_abi(factory: AppFactory) -> None: + _, created_app = factory.deploy( + deploy_time_params={ + "VALUE": 1, + }, + deletable=True, + populate_app_call_resources=False, + ) + + _, replaced_app = factory.deploy( + deploy_time_params={ + "VALUE": 2, + }, + on_update=OnUpdate.ReplaceApp, + create_params=AppClientMethodCallParams(method="create_abi", args=["arg_io"]), + delete_params=AppClientMethodCallParams(method="delete_abi", args=["arg2_io"]), + ) + + assert replaced_app.operation_performed == OperationPerformed.Replace + assert replaced_app.app_id > created_app.app_id + assert replaced_app.app_address == algosdk.logic.get_application_address(replaced_app.app_id) + assert replaced_app.confirmation is not None + assert replaced_app.delete_result is not None + assert replaced_app.delete_result.confirmation is not None + assert len(replaced_app.transactions) == 2 + assert replaced_app.delete_result.transaction.application_call + assert replaced_app.delete_result.transaction.application_call.index == created_app.app_id + assert replaced_app.delete_result.transaction.application_call.on_complete == OnComplete.DeleteApplicationOC + assert replaced_app.return_value == "arg_io" + assert replaced_app.delete_return_value == "arg2_io" + + +def test_create_then_call_app(factory: AppFactory) -> None: + app_client, _ = factory.send.bare.create( + AppFactoryCreateWithSendParams( + deploy_time_params={ + "UPDATABLE": 1, + "DELETABLE": 1, + "VALUE": 1, + }, + ) + ) + + call = app_client.send.call(AppClientMethodCallWithSendParams(method="call_abi", args=["test"])) + + assert call.return_value + assert call.return_value.return_value == "Hello, test" + + +def test_call_app_with_rekey(funded_account: Account, algorand: AlgorandClient, factory: AppFactory) -> None: + rekey_to = algorand.account.random() + + app_client, _ = factory.send.bare.create( + AppFactoryCreateWithSendParams( + deploy_time_params={ + "UPDATABLE": 1, + "DELETABLE": 1, + "VALUE": 1, + }, + ) + ) + + app_client.send.opt_in(AppClientMethodCallWithSendParams(method="opt_in", rekey_to=rekey_to.address)) + + # If the rekey didn't work this will throw + rekeyed_account = algorand.account.rekeyed(funded_account.address, rekey_to) + algorand.send.payment( + PaymentParams(amount=AlgoAmount.from_algo(0), sender=rekeyed_account.address, receiver=funded_account.address) + ) + + +def test_create_app_with_abi(factory: AppFactory) -> None: + _, call_return = factory.send.create( + AppFactoryCreateMethodCallParams( + method="create_abi", + args=["string_io"], + deploy_time_params={ + "UPDATABLE": 0, + "DELETABLE": 0, + "VALUE": 1, + }, + ) + ) + + assert call_return.return_value + # Fix return value issues + assert call_return.return_value.return_value == "string_io" + + +def test_update_app_with_abi(factory: AppFactory) -> None: + deploy_time_params = { + "UPDATABLE": 1, + "DELETABLE": 0, + "VALUE": 1, + } + app_client, _ = factory.send.bare.create( + AppFactoryCreateWithSendParams( + deploy_time_params=deploy_time_params, + ) + ) + + call_return = app_client.send.update( + AppClientMethodCallWithCompilationAndSendParams( + method="update_abi", + args=["string_io"], + deploy_time_params=deploy_time_params, + ) + ) + + assert call_return.return_value is not None + assert call_return.return_value.return_value == "string_io" + # TODO: fix this + # assert call_return.compiled_approval is not None + + +def test_delete_app_with_abi(factory: AppFactory) -> None: + app_client, _ = factory.send.bare.create( + AppFactoryCreateWithSendParams( + deploy_time_params={ + "UPDATABLE": 0, + "DELETABLE": 1, + "VALUE": 1, + }, + ) + ) + + call_return = app_client.send.delete( + AppClientMethodCallWithSendParams( + method="delete_abi", + args=["string_io"], + ) + ) + + assert call_return.return_value is not None + assert call_return.return_value.return_value == "string_io" + + +def test_export_import_sourcemaps( + factory: AppFactory, + algorand: AlgorandClient, + funded_account: Account, +) -> None: + # Export source maps from original client + client, app = factory.deploy(deploy_time_params={"VALUE": 1}) + old_sourcemaps = client.export_source_maps() + + # Create new client instance + new_client = AppClient( + AppClientParams( + app_id=app.app_id, + default_sender=funded_account.address, + default_signer=funded_account.signer, + algorand=algorand, + app_spec=client.app_spec, + ) + ) + + # Test error handling before importing source maps + with pytest.raises(LogicError) as exc_info: + new_client.send.call(AppClientMethodCallWithSendParams(method="error")) + + assert "assert failed" in exc_info.value.message + + # Import source maps into new client + new_client.import_source_maps(old_sourcemaps) + + # Test error handling after importing source maps + with pytest.raises(LogicError) as exc_info: + new_client.send.call(AppClientMethodCallWithSendParams(method="error")) + + error = exc_info.value + assert ( + error.trace().strip() + == "// error\n\terror_7:\n\tproto 0 0\n\tintc_0 // 0\n\t// Deliberate error\n\tassert\t\t<-- Error\n\tretsub\n\t\n\t// create\n\tcreate_8:" # noqa: E501 + ) + assert error.pc == 885 + assert error.message == "assert failed pc=885" + assert len(error.transaction_id) == 52 + + +def test_arc56_error_messages_with_dynamic_template_vars_cblock_offset( + arc56_factory: AppFactory, +) -> None: + client, _ = arc56_factory.deploy( + create_params=AppClientMethodCallParams(method="createApplication"), + deploy_time_params={ + "bytes64TmplVar": "0" * 64, + "uint64TmplVar": 123, + "bytes32TmplVar": "0" * 32, + "bytesTmplVar": "foo", + }, + ) + + with pytest.raises(Exception, match="this is an error"): + client.send.call(AppClientMethodCallWithSendParams(method="throwError")) + + +def test_arc56_undefined_error_message_with_dynamic_template_vars_cblock_offset( + arc56_factory: AppFactory, + algorand: AlgorandClient, + funded_account: Account, +) -> None: + # Deploy app with template parameters + client, result = arc56_factory.deploy( + create_params=AppClientMethodCallParams(method="createApplication"), + deploy_time_params={ + "bytes64TmplVar": "0" * 64, + "uint64TmplVar": 0, + "bytes32TmplVar": "0" * 32, + "bytesTmplVar": "foo", + }, + ) + app_id = result.app_id + + # Create new client without source map from compilation + app_client = AppClient( + AppClientParams( + app_id=app_id, + default_sender=funded_account.address, + default_signer=funded_account.signer, + algorand=algorand, + app_spec=client.app_spec, + ) + ) + + # Test error handling + with pytest.raises(LogicError) as exc_info: + app_client.send.call(AppClientMethodCallWithSendParams(method="tmpl")) + + assert ( + exc_info.value.trace().strip() + == "// tests/example-contracts/arc56_templates/templates.algo.ts:14\n\t\t// assert(this.uint64TmplVar)\n\t\tintc 1 // TMPL_uint64TmplVar\n\t\tassert\n\t\tretsub\t\t<-- Error\n\t\n\t// specificLengthTemplateVar()void\n\t*abi_route_specificLengthTemplateVar:\n\t\t// execute specificLengthTemplateVar()void" # noqa: E501 + ) diff --git a/tests/applications/test_app_manager.py b/tests/applications/test_app_manager.py index 8c9c1002..57313d31 100644 --- a/tests/applications/test_app_manager.py +++ b/tests/applications/test_app_manager.py @@ -1,16 +1,26 @@ import pytest + from algokit_utils.applications.app_manager import AppManager from algokit_utils.clients.algorand_client import AlgorandClient from algokit_utils.models.account import Account - +from algokit_utils.models.amount import AlgoAmount from tests.conftest import check_output_stability -@pytest.fixture() -def algorand(funded_account: Account) -> AlgorandClient: - client = AlgorandClient.default_local_net() - client.set_signer(sender=funded_account.address, signer=funded_account.signer) - return client +@pytest.fixture +def algorand() -> AlgorandClient: + return AlgorandClient.default_local_net() + + +@pytest.fixture +def funded_account(algorand: AlgorandClient) -> Account: + new_account = algorand.account.random() + dispenser = algorand.account.localnet_dispenser() + algorand.account.ensure_funded( + new_account, dispenser, AlgoAmount.from_algos(100), min_funding_increment=AlgoAmount.from_algos(1) + ) + algorand.set_signer(sender=new_account.address, signer=new_account.signer) + return new_account def test_template_substitution() -> None: diff --git a/tests/applications/test_utils.py b/tests/applications/test_utils.py new file mode 100644 index 00000000..4806216c --- /dev/null +++ b/tests/applications/test_utils.py @@ -0,0 +1,16 @@ +from pathlib import Path + +from algokit_utils.applications.utils import arc32_to_arc56 +from tests.utils import load_arc32_spec + +TEST_ARC32_SPEC_FILE_PATH = Path(__file__).parent.parent / "artifacts" / "hello_world" / "arc32_app_spec.json" + + +def test_arc32_to_arc56() -> None: + arc32_app_spec = load_arc32_spec( + TEST_ARC32_SPEC_FILE_PATH, deletable=True, updatable=True, template_values={"VERSION": 1} + ) + + arc56_app_spec = arc32_to_arc56(arc32_app_spec) + + assert arc56_app_spec diff --git a/tests/transactions/artifacts/hello_world/approval.teal b/tests/artifacts/hello_world/approval.teal similarity index 100% rename from tests/transactions/artifacts/hello_world/approval.teal rename to tests/artifacts/hello_world/approval.teal diff --git a/tests/artifacts/hello_world/arc32_app_spec.json b/tests/artifacts/hello_world/arc32_app_spec.json new file mode 100644 index 00000000..d84bc32c --- /dev/null +++ b/tests/artifacts/hello_world/arc32_app_spec.json @@ -0,0 +1,55 @@ +{ + "hints": { + "hello(string)string": { + "call_config": { + "no_op": "CALL" + } + } + }, + "source": { + "approval": "I3ByYWdtYSB2ZXJzaW9uIDEwCgpzbWFydF9jb250cmFjdHMuaGVsbG9fd29ybGQuY29udHJhY3QuSGVsbG9Xb3JsZC5hcHByb3ZhbF9wcm9ncmFtOgogICAgaW50Y2Jsb2NrIDAgMQogICAgY2FsbHN1YiBfX3B1eWFfYXJjNF9yb3V0ZXJfXwogICAgcmV0dXJuCgoKLy8gc21hcnRfY29udHJhY3RzLmhlbGxvX3dvcmxkLmNvbnRyYWN0LkhlbGxvV29ybGQuX19wdXlhX2FyYzRfcm91dGVyX18oKSAtPiB1aW50NjQ6Cl9fcHV5YV9hcmM0X3JvdXRlcl9fOgogICAgLy8gc21hcnRfY29udHJhY3RzL2hlbGxvX3dvcmxkL2NvbnRyYWN0LnB5OjUKICAgIC8vIGNsYXNzIEhlbGxvV29ybGQoQVJDNENvbnRyYWN0KToKICAgIHByb3RvIDAgMQogICAgdHhuIE51bUFwcEFyZ3MKICAgIGJ6IF9fcHV5YV9hcmM0X3JvdXRlcl9fX2JhcmVfcm91dGluZ0A1CiAgICBwdXNoYnl0ZXMgMHgwMmJlY2UxMSAvLyBtZXRob2QgImhlbGxvKHN0cmluZylzdHJpbmciCiAgICB0eG5hIEFwcGxpY2F0aW9uQXJncyAwCiAgICBtYXRjaCBfX3B1eWFfYXJjNF9yb3V0ZXJfX19oZWxsb19yb3V0ZUAyCiAgICBpbnRjXzAgLy8gMAogICAgcmV0c3ViCgpfX3B1eWFfYXJjNF9yb3V0ZXJfX19oZWxsb19yb3V0ZUAyOgogICAgLy8gc21hcnRfY29udHJhY3RzL2hlbGxvX3dvcmxkL2NvbnRyYWN0LnB5OjYKICAgIC8vIEBhYmltZXRob2QoKQogICAgdHhuIE9uQ29tcGxldGlvbgogICAgIQogICAgYXNzZXJ0IC8vIE9uQ29tcGxldGlvbiBpcyBOb09wCiAgICB0eG4gQXBwbGljYXRpb25JRAogICAgYXNzZXJ0IC8vIGlzIG5vdCBjcmVhdGluZwogICAgLy8gc21hcnRfY29udHJhY3RzL2hlbGxvX3dvcmxkL2NvbnRyYWN0LnB5OjUKICAgIC8vIGNsYXNzIEhlbGxvV29ybGQoQVJDNENvbnRyYWN0KToKICAgIHR4bmEgQXBwbGljYXRpb25BcmdzIDEKICAgIGV4dHJhY3QgMiAwCiAgICAvLyBzbWFydF9jb250cmFjdHMvaGVsbG9fd29ybGQvY29udHJhY3QucHk6NgogICAgLy8gQGFiaW1ldGhvZCgpCiAgICBjYWxsc3ViIGhlbGxvCiAgICBkdXAKICAgIGxlbgogICAgaXRvYgogICAgZXh0cmFjdCA2IDIKICAgIHN3YXAKICAgIGNvbmNhdAogICAgcHVzaGJ5dGVzIDB4MTUxZjdjNzUKICAgIHN3YXAKICAgIGNvbmNhdAogICAgbG9nCiAgICBpbnRjXzEgLy8gMQogICAgcmV0c3ViCgpfX3B1eWFfYXJjNF9yb3V0ZXJfX19iYXJlX3JvdXRpbmdANToKICAgIC8vIHNtYXJ0X2NvbnRyYWN0cy9oZWxsb193b3JsZC9jb250cmFjdC5weTo1CiAgICAvLyBjbGFzcyBIZWxsb1dvcmxkKEFSQzRDb250cmFjdCk6CiAgICB0eG4gT25Db21wbGV0aW9uCiAgICBibnogX19wdXlhX2FyYzRfcm91dGVyX19fYWZ0ZXJfaWZfZWxzZUA5CiAgICB0eG4gQXBwbGljYXRpb25JRAogICAgIQogICAgYXNzZXJ0IC8vIGlzIGNyZWF0aW5nCiAgICBpbnRjXzEgLy8gMQogICAgcmV0c3ViCgpfX3B1eWFfYXJjNF9yb3V0ZXJfX19hZnRlcl9pZl9lbHNlQDk6CiAgICAvLyBzbWFydF9jb250cmFjdHMvaGVsbG9fd29ybGQvY29udHJhY3QucHk6NQogICAgLy8gY2xhc3MgSGVsbG9Xb3JsZChBUkM0Q29udHJhY3QpOgogICAgaW50Y18wIC8vIDAKICAgIHJldHN1YgoKCi8vIHNtYXJ0X2NvbnRyYWN0cy5oZWxsb193b3JsZC5jb250cmFjdC5IZWxsb1dvcmxkLmhlbGxvKG5hbWU6IGJ5dGVzKSAtPiBieXRlczoKaGVsbG86CiAgICAvLyBzbWFydF9jb250cmFjdHMvaGVsbG9fd29ybGQvY29udHJhY3QucHk6Ni03CiAgICAvLyBAYWJpbWV0aG9kKCkKICAgIC8vIGRlZiBoZWxsbyhzZWxmLCBuYW1lOiBTdHJpbmcpIC0+IFN0cmluZzoKICAgIHByb3RvIDEgMQogICAgLy8gc21hcnRfY29udHJhY3RzL2hlbGxvX3dvcmxkL2NvbnRyYWN0LnB5OjgKICAgIC8vIHJldHVybiAiSGVsbG8yLCAiICsgbmFtZQogICAgcHVzaGJ5dGVzICJIZWxsbzIsICIKICAgIGZyYW1lX2RpZyAtMQogICAgY29uY2F0CiAgICByZXRzdWIK", + "clear": "I3ByYWdtYSB2ZXJzaW9uIDEwCgpzbWFydF9jb250cmFjdHMuaGVsbG9fd29ybGQuY29udHJhY3QuSGVsbG9Xb3JsZC5jbGVhcl9zdGF0ZV9wcm9ncmFtOgogICAgcHVzaGludCAxIC8vIDEKICAgIHJldHVybgo=" + }, + "state": { + "global": { + "num_byte_slices": 0, + "num_uints": 0 + }, + "local": { + "num_byte_slices": 0, + "num_uints": 0 + } + }, + "schema": { + "global": { + "declared": {}, + "reserved": {} + }, + "local": { + "declared": {}, + "reserved": {} + } + }, + "contract": { + "name": "HelloWorld", + "methods": [ + { + "name": "hello", + "args": [ + { + "type": "string", + "name": "name" + } + ], + "readonly": false, + "returns": { + "type": "string" + } + } + ], + "networks": {} + }, + "bare_call_config": { + "no_op": "CREATE" + } +} diff --git a/tests/transactions/artifacts/hello_world/clear.teal b/tests/artifacts/hello_world/clear.teal similarity index 100% rename from tests/transactions/artifacts/hello_world/clear.teal rename to tests/artifacts/hello_world/clear.teal diff --git a/tests/artifacts/legacy_hello_world/arc32_app_spec.json b/tests/artifacts/legacy_hello_world/arc32_app_spec.json new file mode 100644 index 00000000..1ddf81b2 --- /dev/null +++ b/tests/artifacts/legacy_hello_world/arc32_app_spec.json @@ -0,0 +1,378 @@ +{ + "hints": { + "version()uint64": { + "call_config": { + "no_op": "CALL" + } + }, + "readonly(uint64)void": { + "read_only": true, + "call_config": { + "no_op": "CALL" + } + }, + "set_box(byte[4],string)void": { + "call_config": { + "no_op": "CALL" + } + }, + "get_box(byte[4])string": { + "call_config": { + "no_op": "CALL" + } + }, + "get_box_readonly(byte[4])string": { + "read_only": true, + "call_config": { + "no_op": "CALL" + } + }, + "update()void": { + "call_config": { + "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": { + "call_config": { + "no_op": "CALL" + } + }, + "create()void": { + "call_config": { + "no_op": "CREATE" + } + }, + "create_args(string)void": { + "call_config": { + "no_op": "CREATE" + } + }, + "hello(string)string": { + "read_only": true, + "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" + } + }, + "call_with_payment(pay)string": { + "call_config": { + "no_op": "CALL" + } + } + }, + "source": { + "approval": "I3ByYWdtYSB2ZXJzaW9uIDgKaW50Y2Jsb2NrIDAgMSAyIDUgVE1QTF9VUERBVEFCTEUgVE1QTF9ERUxFVEFCTEUKYnl0ZWNibG9jayAweCAweDY3NzI2NTY1NzQ2OTZlNjcgMHgxNTFmN2M3NSAweDZjNjE3Mzc0IDB4NTk2NTczIDB4MmMyMAp0eG4gTnVtQXBwQXJncwppbnRjXzAgLy8gMAo9PQpibnogbWFpbl9sNDQKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMApwdXNoYnl0ZXMgMHgxOWQ2YjE4NiAvLyAidmVyc2lvbigpdWludDY0Igo9PQpibnogbWFpbl9sNDMKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMApwdXNoYnl0ZXMgMHg1M2JkNjE4NiAvLyAicmVhZG9ubHkodWludDY0KXZvaWQiCj09CmJueiBtYWluX2w0Mgp0eG5hIEFwcGxpY2F0aW9uQXJncyAwCnB1c2hieXRlcyAweGE0YjRhMjMwIC8vICJzZXRfYm94KGJ5dGVbNF0sc3RyaW5nKXZvaWQiCj09CmJueiBtYWluX2w0MQp0eG5hIEFwcGxpY2F0aW9uQXJncyAwCnB1c2hieXRlcyAweDdmNWRlMjhmIC8vICJnZXRfYm94KGJ5dGVbNF0pc3RyaW5nIgo9PQpibnogbWFpbl9sNDAKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMApwdXNoYnl0ZXMgMHgxM2QxMmI1MCAvLyAiZ2V0X2JveF9yZWFkb25seShieXRlWzRdKXN0cmluZyIKPT0KYm56IG1haW5fbDM5CnR4bmEgQXBwbGljYXRpb25BcmdzIDAKcHVzaGJ5dGVzIDB4YTBlODE4NzIgLy8gInVwZGF0ZSgpdm9pZCIKPT0KYm56IG1haW5fbDM4CnR4bmEgQXBwbGljYXRpb25BcmdzIDAKcHVzaGJ5dGVzIDB4N2QwODUxOGIgLy8gInVwZGF0ZV9hcmdzKHN0cmluZyl2b2lkIgo9PQpibnogbWFpbl9sMzcKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMApwdXNoYnl0ZXMgMHgyNDM3OGQzYyAvLyAiZGVsZXRlKCl2b2lkIgo9PQpibnogbWFpbl9sMzYKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMApwdXNoYnl0ZXMgMHg1ODYxYmI1MCAvLyAiZGVsZXRlX2FyZ3Moc3RyaW5nKXZvaWQiCj09CmJueiBtYWluX2wzNQp0eG5hIEFwcGxpY2F0aW9uQXJncyAwCnB1c2hieXRlcyAweDhiZGY5ZWIwIC8vICJjcmVhdGVfb3B0X2luKCl2b2lkIgo9PQpibnogbWFpbl9sMzQKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMApwdXNoYnl0ZXMgMHgwMDU1ZjAwNiAvLyAidXBkYXRlX2dyZWV0aW5nKHN0cmluZyl2b2lkIgo9PQpibnogbWFpbl9sMzMKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMApwdXNoYnl0ZXMgMHg0YzVjNjFiYSAvLyAiY3JlYXRlKCl2b2lkIgo9PQpibnogbWFpbl9sMzIKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMApwdXNoYnl0ZXMgMHhkMTQ1NGM3OCAvLyAiY3JlYXRlX2FyZ3Moc3RyaW5nKXZvaWQiCj09CmJueiBtYWluX2wzMQp0eG5hIEFwcGxpY2F0aW9uQXJncyAwCnB1c2hieXRlcyAweDAyYmVjZTExIC8vICJoZWxsbyhzdHJpbmcpc3RyaW5nIgo9PQpibnogbWFpbl9sMzAKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMApwdXNoYnl0ZXMgMHhiYzFjMWRkNCAvLyAiaGVsbG9fcmVtZW1iZXIoc3RyaW5nKXN0cmluZyIKPT0KYm56IG1haW5fbDI5CnR4bmEgQXBwbGljYXRpb25BcmdzIDAKcHVzaGJ5dGVzIDB4YTlhZTc2MjcgLy8gImdldF9sYXN0KClzdHJpbmciCj09CmJueiBtYWluX2wyOAp0eG5hIEFwcGxpY2F0aW9uQXJncyAwCnB1c2hieXRlcyAweDMwYzZkNThhIC8vICJvcHRfaW4oKXZvaWQiCj09CmJueiBtYWluX2wyNwp0eG5hIEFwcGxpY2F0aW9uQXJncyAwCnB1c2hieXRlcyAweDIyYzdkZWRhIC8vICJvcHRfaW5fYXJncyhzdHJpbmcpdm9pZCIKPT0KYm56IG1haW5fbDI2CnR4bmEgQXBwbGljYXRpb25BcmdzIDAKcHVzaGJ5dGVzIDB4MTY1OGFhMmYgLy8gImNsb3NlX291dCgpdm9pZCIKPT0KYm56IG1haW5fbDI1CnR4bmEgQXBwbGljYXRpb25BcmdzIDAKcHVzaGJ5dGVzIDB4ZGU4NGQ5YWQgLy8gImNsb3NlX291dF9hcmdzKHN0cmluZyl2b2lkIgo9PQpibnogbWFpbl9sMjQKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMApwdXNoYnl0ZXMgMHg4ODk2M2M5OSAvLyAiY2FsbF93aXRoX3BheW1lbnQocGF5KXN0cmluZyIKPT0KYm56IG1haW5fbDIzCmVycgptYWluX2wyMzoKdHhuIE9uQ29tcGxldGlvbgppbnRjXzAgLy8gTm9PcAo9PQp0eG4gQXBwbGljYXRpb25JRAppbnRjXzAgLy8gMAohPQomJgphc3NlcnQKY2FsbHN1YiBjYWxsd2l0aHBheW1lbnRjYXN0ZXJfNDYKaW50Y18xIC8vIDEKcmV0dXJuCm1haW5fbDI0Ogp0eG4gT25Db21wbGV0aW9uCmludGNfMiAvLyBDbG9zZU91dAo9PQp0eG4gQXBwbGljYXRpb25JRAppbnRjXzAgLy8gMAohPQomJgphc3NlcnQKY2FsbHN1YiBjbG9zZW91dGFyZ3NjYXN0ZXJfNDUKaW50Y18xIC8vIDEKcmV0dXJuCm1haW5fbDI1Ogp0eG4gT25Db21wbGV0aW9uCmludGNfMiAvLyBDbG9zZU91dAo9PQp0eG4gQXBwbGljYXRpb25JRAppbnRjXzAgLy8gMAohPQomJgphc3NlcnQKY2FsbHN1YiBjbG9zZW91dGNhc3Rlcl80NAppbnRjXzEgLy8gMQpyZXR1cm4KbWFpbl9sMjY6CnR4biBPbkNvbXBsZXRpb24KaW50Y18xIC8vIE9wdEluCj09CnR4biBBcHBsaWNhdGlvbklECmludGNfMCAvLyAwCiE9CiYmCmFzc2VydApjYWxsc3ViIG9wdGluYXJnc2Nhc3Rlcl80MwppbnRjXzEgLy8gMQpyZXR1cm4KbWFpbl9sMjc6CnR4biBPbkNvbXBsZXRpb24KaW50Y18xIC8vIE9wdEluCj09CnR4biBBcHBsaWNhdGlvbklECmludGNfMCAvLyAwCiE9CiYmCmFzc2VydApjYWxsc3ViIG9wdGluY2FzdGVyXzQyCmludGNfMSAvLyAxCnJldHVybgptYWluX2wyODoKdHhuIE9uQ29tcGxldGlvbgppbnRjXzAgLy8gTm9PcAo9PQp0eG4gQXBwbGljYXRpb25JRAppbnRjXzAgLy8gMAohPQomJgphc3NlcnQKY2FsbHN1YiBnZXRsYXN0Y2FzdGVyXzQxCmludGNfMSAvLyAxCnJldHVybgptYWluX2wyOToKdHhuIE9uQ29tcGxldGlvbgppbnRjXzAgLy8gTm9PcAo9PQp0eG4gQXBwbGljYXRpb25JRAppbnRjXzAgLy8gMAohPQomJgphc3NlcnQKY2FsbHN1YiBoZWxsb3JlbWVtYmVyY2FzdGVyXzQwCmludGNfMSAvLyAxCnJldHVybgptYWluX2wzMDoKdHhuIE9uQ29tcGxldGlvbgppbnRjXzAgLy8gTm9PcAo9PQp0eG4gQXBwbGljYXRpb25JRAppbnRjXzAgLy8gMAohPQomJgphc3NlcnQKY2FsbHN1YiBoZWxsb2Nhc3Rlcl8zOQppbnRjXzEgLy8gMQpyZXR1cm4KbWFpbl9sMzE6CnR4biBPbkNvbXBsZXRpb24KaW50Y18wIC8vIE5vT3AKPT0KdHhuIEFwcGxpY2F0aW9uSUQKaW50Y18wIC8vIDAKPT0KJiYKYXNzZXJ0CmNhbGxzdWIgY3JlYXRlYXJnc2Nhc3Rlcl8zOAppbnRjXzEgLy8gMQpyZXR1cm4KbWFpbl9sMzI6CnR4biBPbkNvbXBsZXRpb24KaW50Y18wIC8vIE5vT3AKPT0KdHhuIEFwcGxpY2F0aW9uSUQKaW50Y18wIC8vIDAKPT0KJiYKYXNzZXJ0CmNhbGxzdWIgY3JlYXRlY2FzdGVyXzM3CmludGNfMSAvLyAxCnJldHVybgptYWluX2wzMzoKdHhuIE9uQ29tcGxldGlvbgppbnRjXzAgLy8gTm9PcAo9PQp0eG4gQXBwbGljYXRpb25JRAppbnRjXzAgLy8gMAohPQomJgphc3NlcnQKY2FsbHN1YiB1cGRhdGVncmVldGluZ2Nhc3Rlcl8zNgppbnRjXzEgLy8gMQpyZXR1cm4KbWFpbl9sMzQ6CnR4biBPbkNvbXBsZXRpb24KaW50Y18xIC8vIE9wdEluCj09CnR4biBBcHBsaWNhdGlvbklECmludGNfMCAvLyAwCj09CiYmCmFzc2VydApjYWxsc3ViIGNyZWF0ZW9wdGluY2FzdGVyXzM1CmludGNfMSAvLyAxCnJldHVybgptYWluX2wzNToKdHhuIE9uQ29tcGxldGlvbgppbnRjXzMgLy8gRGVsZXRlQXBwbGljYXRpb24KPT0KdHhuIEFwcGxpY2F0aW9uSUQKaW50Y18wIC8vIDAKIT0KJiYKYXNzZXJ0CmNhbGxzdWIgZGVsZXRlYXJnc2Nhc3Rlcl8zNAppbnRjXzEgLy8gMQpyZXR1cm4KbWFpbl9sMzY6CnR4biBPbkNvbXBsZXRpb24KaW50Y18zIC8vIERlbGV0ZUFwcGxpY2F0aW9uCj09CnR4biBBcHBsaWNhdGlvbklECmludGNfMCAvLyAwCiE9CiYmCmFzc2VydApjYWxsc3ViIGRlbGV0ZWNhc3Rlcl8zMwppbnRjXzEgLy8gMQpyZXR1cm4KbWFpbl9sMzc6CnR4biBPbkNvbXBsZXRpb24KcHVzaGludCA0IC8vIFVwZGF0ZUFwcGxpY2F0aW9uCj09CnR4biBBcHBsaWNhdGlvbklECmludGNfMCAvLyAwCiE9CiYmCmFzc2VydApjYWxsc3ViIHVwZGF0ZWFyZ3NjYXN0ZXJfMzIKaW50Y18xIC8vIDEKcmV0dXJuCm1haW5fbDM4Ogp0eG4gT25Db21wbGV0aW9uCnB1c2hpbnQgNCAvLyBVcGRhdGVBcHBsaWNhdGlvbgo9PQp0eG4gQXBwbGljYXRpb25JRAppbnRjXzAgLy8gMAohPQomJgphc3NlcnQKY2FsbHN1YiB1cGRhdGVjYXN0ZXJfMzEKaW50Y18xIC8vIDEKcmV0dXJuCm1haW5fbDM5Ogp0eG4gT25Db21wbGV0aW9uCmludGNfMCAvLyBOb09wCj09CnR4biBBcHBsaWNhdGlvbklECmludGNfMCAvLyAwCiE9CiYmCmFzc2VydApjYWxsc3ViIGdldGJveHJlYWRvbmx5Y2FzdGVyXzMwCmludGNfMSAvLyAxCnJldHVybgptYWluX2w0MDoKdHhuIE9uQ29tcGxldGlvbgppbnRjXzAgLy8gTm9PcAo9PQp0eG4gQXBwbGljYXRpb25JRAppbnRjXzAgLy8gMAohPQomJgphc3NlcnQKY2FsbHN1YiBnZXRib3hjYXN0ZXJfMjkKaW50Y18xIC8vIDEKcmV0dXJuCm1haW5fbDQxOgp0eG4gT25Db21wbGV0aW9uCmludGNfMCAvLyBOb09wCj09CnR4biBBcHBsaWNhdGlvbklECmludGNfMCAvLyAwCiE9CiYmCmFzc2VydApjYWxsc3ViIHNldGJveGNhc3Rlcl8yOAppbnRjXzEgLy8gMQpyZXR1cm4KbWFpbl9sNDI6CnR4biBPbkNvbXBsZXRpb24KaW50Y18wIC8vIE5vT3AKPT0KdHhuIEFwcGxpY2F0aW9uSUQKaW50Y18wIC8vIDAKIT0KJiYKYXNzZXJ0CmNhbGxzdWIgcmVhZG9ubHljYXN0ZXJfMjcKaW50Y18xIC8vIDEKcmV0dXJuCm1haW5fbDQzOgp0eG4gT25Db21wbGV0aW9uCmludGNfMCAvLyBOb09wCj09CnR4biBBcHBsaWNhdGlvbklECmludGNfMCAvLyAwCiE9CiYmCmFzc2VydApjYWxsc3ViIHZlcnNpb25jYXN0ZXJfMjYKaW50Y18xIC8vIDEKcmV0dXJuCm1haW5fbDQ0Ogp0eG4gT25Db21wbGV0aW9uCmludGNfMCAvLyBOb09wCj09CmJueiBtYWluX2w1NAp0eG4gT25Db21wbGV0aW9uCmludGNfMSAvLyBPcHRJbgo9PQpibnogbWFpbl9sNTMKdHhuIE9uQ29tcGxldGlvbgppbnRjXzIgLy8gQ2xvc2VPdXQKPT0KYm56IG1haW5fbDUyCnR4biBPbkNvbXBsZXRpb24KcHVzaGludCA0IC8vIFVwZGF0ZUFwcGxpY2F0aW9uCj09CmJueiBtYWluX2w1MQp0eG4gT25Db21wbGV0aW9uCmludGNfMyAvLyBEZWxldGVBcHBsaWNhdGlvbgo9PQpibnogbWFpbl9sNTAKZXJyCm1haW5fbDUwOgp0eG4gQXBwbGljYXRpb25JRAppbnRjXzAgLy8gMAohPQphc3NlcnQKY2FsbHN1YiBkZWxldGViYXJlXzkKaW50Y18xIC8vIDEKcmV0dXJuCm1haW5fbDUxOgp0eG4gQXBwbGljYXRpb25JRAppbnRjXzAgLy8gMAohPQphc3NlcnQKY2FsbHN1YiB1cGRhdGViYXJlXzYKaW50Y18xIC8vIDEKcmV0dXJuCm1haW5fbDUyOgp0eG4gQXBwbGljYXRpb25JRAppbnRjXzAgLy8gMAohPQphc3NlcnQKY2FsbHN1YiBjbG9zZW91dGJhcmVfMjMKaW50Y18xIC8vIDEKcmV0dXJuCm1haW5fbDUzOgp0eG4gQXBwbGljYXRpb25JRAppbnRjXzAgLy8gMAohPQphc3NlcnQKY2FsbHN1YiBvcHRpbmJhcmVfMjAKaW50Y18xIC8vIDEKcmV0dXJuCm1haW5fbDU0Ogp0eG4gQXBwbGljYXRpb25JRAppbnRjXzAgLy8gMAo9PQphc3NlcnQKY2FsbHN1YiBjcmVhdGViYXJlXzEzCmludGNfMSAvLyAxCnJldHVybgoKLy8gdmVyc2lvbgp2ZXJzaW9uXzA6CnByb3RvIDAgMQppbnRjXzAgLy8gMApwdXNoaW50IFRNUExfVkVSU0lPTiAvLyBUTVBMX1ZFUlNJT04KZnJhbWVfYnVyeSAwCnJldHN1YgoKLy8gcmVhZG9ubHkKcmVhZG9ubHlfMToKcHJvdG8gMSAwCmZyYW1lX2RpZyAtMQpibnogcmVhZG9ubHlfMV9sMgppbnRjXzEgLy8gMQpyZXR1cm4KcmVhZG9ubHlfMV9sMjoKaW50Y18wIC8vIDAKLy8gQW4gZXJyb3IKYXNzZXJ0CnJldHN1YgoKLy8gc2V0X2JveApzZXRib3hfMjoKcHJvdG8gMiAwCmZyYW1lX2RpZyAtMgpib3hfZGVsCnBvcApmcmFtZV9kaWcgLTIKZnJhbWVfZGlnIC0xCmV4dHJhY3QgMiAwCmJveF9wdXQKcmV0c3ViCgovLyBnZXRfYm94CmdldGJveF8zOgpwcm90byAxIDEKYnl0ZWNfMCAvLyAiIgpmcmFtZV9kaWcgLTEKYm94X2dldApzdG9yZSAxCnN0b3JlIDAKbG9hZCAxCmFzc2VydApsb2FkIDAKZnJhbWVfYnVyeSAwCmZyYW1lX2RpZyAwCmxlbgppdG9iCmV4dHJhY3QgNiAwCmZyYW1lX2RpZyAwCmNvbmNhdApmcmFtZV9idXJ5IDAKcmV0c3ViCgovLyBnZXRfYm94X3JlYWRvbmx5CmdldGJveHJlYWRvbmx5XzQ6CnByb3RvIDEgMQpieXRlY18wIC8vICIiCmZyYW1lX2RpZyAtMQpib3hfZ2V0CnN0b3JlIDMKc3RvcmUgMgpsb2FkIDMKYXNzZXJ0CmxvYWQgMgpmcmFtZV9idXJ5IDAKZnJhbWVfZGlnIDAKbGVuCml0b2IKZXh0cmFjdCA2IDAKZnJhbWVfZGlnIDAKY29uY2F0CmZyYW1lX2J1cnkgMApyZXRzdWIKCi8vIHVwZGF0ZQp1cGRhdGVfNToKcHJvdG8gMCAwCnR4biBTZW5kZXIKZ2xvYmFsIENyZWF0b3JBZGRyZXNzCj09Ci8vIHVuYXV0aG9yaXplZAphc3NlcnQKaW50YyA0IC8vIFRNUExfVVBEQVRBQkxFCi8vIGlzIHVwZGF0YWJsZQphc3NlcnQKYnl0ZWNfMSAvLyAiZ3JlZXRpbmciCnB1c2hieXRlcyAweDU1NzA2NDYxNzQ2NTY0MjA0MTQyNDkgLy8gIlVwZGF0ZWQgQUJJIgphcHBfZ2xvYmFsX3B1dApyZXRzdWIKCi8vIHVwZGF0ZV9iYXJlCnVwZGF0ZWJhcmVfNjoKcHJvdG8gMCAwCnR4biBTZW5kZXIKZ2xvYmFsIENyZWF0b3JBZGRyZXNzCj09Ci8vIHVuYXV0aG9yaXplZAphc3NlcnQKaW50YyA0IC8vIFRNUExfVVBEQVRBQkxFCi8vIGlzIHVwZGF0YWJsZQphc3NlcnQKYnl0ZWNfMSAvLyAiZ3JlZXRpbmciCnB1c2hieXRlcyAweDU1NzA2NDYxNzQ2NTY0MjA0MjYxNzI2NSAvLyAiVXBkYXRlZCBCYXJlIgphcHBfZ2xvYmFsX3B1dApyZXRzdWIKCi8vIHVwZGF0ZV9hcmdzCnVwZGF0ZWFyZ3NfNzoKcHJvdG8gMSAwCnR4biBTZW5kZXIKZ2xvYmFsIENyZWF0b3JBZGRyZXNzCj09Ci8vIHVuYXV0aG9yaXplZAphc3NlcnQKZnJhbWVfZGlnIC0xCmV4dHJhY3QgMiAwCmJ5dGVjIDQgLy8gIlllcyIKPT0KLy8gcGFzc2VzIHVwZGF0ZSBjaGVjawphc3NlcnQKaW50YyA0IC8vIFRNUExfVVBEQVRBQkxFCi8vIGlzIHVwZGF0YWJsZQphc3NlcnQKYnl0ZWNfMSAvLyAiZ3JlZXRpbmciCnB1c2hieXRlcyAweDU1NzA2NDYxNzQ2NTY0MjA0MTcyNjc3MyAvLyAiVXBkYXRlZCBBcmdzIgphcHBfZ2xvYmFsX3B1dApyZXRzdWIKCi8vIGRlbGV0ZQpkZWxldGVfODoKcHJvdG8gMCAwCnR4biBTZW5kZXIKZ2xvYmFsIENyZWF0b3JBZGRyZXNzCj09Ci8vIHVuYXV0aG9yaXplZAphc3NlcnQKaW50YyA1IC8vIFRNUExfREVMRVRBQkxFCi8vIGlzIGRlbGV0YWJsZQphc3NlcnQKcmV0c3ViCgovLyBkZWxldGVfYmFyZQpkZWxldGViYXJlXzk6CnByb3RvIDAgMAp0eG4gU2VuZGVyCmdsb2JhbCBDcmVhdG9yQWRkcmVzcwo9PQovLyB1bmF1dGhvcml6ZWQKYXNzZXJ0CmludGMgNSAvLyBUTVBMX0RFTEVUQUJMRQovLyBpcyBkZWxldGFibGUKYXNzZXJ0CnJldHN1YgoKLy8gZGVsZXRlX2FyZ3MKZGVsZXRlYXJnc18xMDoKcHJvdG8gMSAwCnR4biBTZW5kZXIKZ2xvYmFsIENyZWF0b3JBZGRyZXNzCj09Ci8vIHVuYXV0aG9yaXplZAphc3NlcnQKZnJhbWVfZGlnIC0xCmV4dHJhY3QgMiAwCmJ5dGVjIDQgLy8gIlllcyIKPT0KLy8gcGFzc2VzIGRlbGV0ZSBjaGVjawphc3NlcnQKaW50YyA1IC8vIFRNUExfREVMRVRBQkxFCi8vIGlzIGRlbGV0YWJsZQphc3NlcnQKcmV0c3ViCgovLyBjcmVhdGVfb3B0X2luCmNyZWF0ZW9wdGluXzExOgpwcm90byAwIDAKYnl0ZWNfMSAvLyAiZ3JlZXRpbmciCnB1c2hieXRlcyAweDRmNzA3NDIwNDk2ZSAvLyAiT3B0IEluIgphcHBfZ2xvYmFsX3B1dAppbnRjXzEgLy8gMQpyZXR1cm4KCi8vIHVwZGF0ZV9ncmVldGluZwp1cGRhdGVncmVldGluZ18xMjoKcHJvdG8gMSAwCmJ5dGVjXzEgLy8gImdyZWV0aW5nIgpmcmFtZV9kaWcgLTEKZXh0cmFjdCAyIDAKYXBwX2dsb2JhbF9wdXQKcmV0c3ViCgovLyBjcmVhdGVfYmFyZQpjcmVhdGViYXJlXzEzOgpwcm90byAwIDAKYnl0ZWNfMSAvLyAiZ3JlZXRpbmciCnB1c2hieXRlcyAweDQ4NjU2YzZjNmYyMDQyNjE3MjY1IC8vICJIZWxsbyBCYXJlIgphcHBfZ2xvYmFsX3B1dAppbnRjXzEgLy8gMQpyZXR1cm4KCi8vIGNyZWF0ZQpjcmVhdGVfMTQ6CnByb3RvIDAgMApieXRlY18xIC8vICJncmVldGluZyIKcHVzaGJ5dGVzIDB4NDg2NTZjNmM2ZjIwNDE0MjQ5IC8vICJIZWxsbyBBQkkiCmFwcF9nbG9iYWxfcHV0CmludGNfMSAvLyAxCnJldHVybgoKLy8gY3JlYXRlX2FyZ3MKY3JlYXRlYXJnc18xNToKcHJvdG8gMSAwCmJ5dGVjXzEgLy8gImdyZWV0aW5nIgpmcmFtZV9kaWcgLTEKZXh0cmFjdCAyIDAKYXBwX2dsb2JhbF9wdXQKaW50Y18xIC8vIDEKcmV0dXJuCgovLyBoZWxsbwpoZWxsb18xNjoKcHJvdG8gMSAxCmJ5dGVjXzAgLy8gIiIKYnl0ZWNfMSAvLyAiZ3JlZXRpbmciCmFwcF9nbG9iYWxfZ2V0CmJ5dGVjIDUgLy8gIiwgIgpjb25jYXQKZnJhbWVfZGlnIC0xCmV4dHJhY3QgMiAwCmNvbmNhdApmcmFtZV9idXJ5IDAKZnJhbWVfZGlnIDAKbGVuCml0b2IKZXh0cmFjdCA2IDAKZnJhbWVfZGlnIDAKY29uY2F0CmZyYW1lX2J1cnkgMApyZXRzdWIKCi8vIGhlbGxvX3JlbWVtYmVyCmhlbGxvcmVtZW1iZXJfMTc6CnByb3RvIDEgMQpieXRlY18wIC8vICIiCnR4biBTZW5kZXIKYnl0ZWNfMyAvLyAibGFzdCIKZnJhbWVfZGlnIC0xCmV4dHJhY3QgMiAwCmFwcF9sb2NhbF9wdXQKYnl0ZWNfMSAvLyAiZ3JlZXRpbmciCmFwcF9nbG9iYWxfZ2V0CmJ5dGVjIDUgLy8gIiwgIgpjb25jYXQKZnJhbWVfZGlnIC0xCmV4dHJhY3QgMiAwCmNvbmNhdApmcmFtZV9idXJ5IDAKZnJhbWVfZGlnIDAKbGVuCml0b2IKZXh0cmFjdCA2IDAKZnJhbWVfZGlnIDAKY29uY2F0CmZyYW1lX2J1cnkgMApyZXRzdWIKCi8vIGdldF9sYXN0CmdldGxhc3RfMTg6CnByb3RvIDAgMQpieXRlY18wIC8vICIiCnR4biBTZW5kZXIKYnl0ZWNfMyAvLyAibGFzdCIKYXBwX2xvY2FsX2dldApmcmFtZV9idXJ5IDAKZnJhbWVfZGlnIDAKbGVuCml0b2IKZXh0cmFjdCA2IDAKZnJhbWVfZGlnIDAKY29uY2F0CmZyYW1lX2J1cnkgMApyZXRzdWIKCi8vIG9wdF9pbgpvcHRpbl8xOToKcHJvdG8gMCAwCnR4biBTZW5kZXIKYnl0ZWNfMyAvLyAibGFzdCIKcHVzaGJ5dGVzIDB4NGY3MDc0MjA0OTZlMjA0MTQyNDkgLy8gIk9wdCBJbiBBQkkiCmFwcF9sb2NhbF9wdXQKaW50Y18xIC8vIDEKcmV0dXJuCgovLyBvcHRfaW5fYmFyZQpvcHRpbmJhcmVfMjA6CnByb3RvIDAgMAp0eG4gU2VuZGVyCmJ5dGVjXzMgLy8gImxhc3QiCnB1c2hieXRlcyAweDRmNzA3NDIwNDk2ZTIwNDI2MTcyNjUgLy8gIk9wdCBJbiBCYXJlIgphcHBfbG9jYWxfcHV0CmludGNfMSAvLyAxCnJldHVybgoKLy8gb3B0X2luX2FyZ3MKb3B0aW5hcmdzXzIxOgpwcm90byAxIDAKZnJhbWVfZGlnIC0xCmV4dHJhY3QgMiAwCmJ5dGVjIDQgLy8gIlllcyIKPT0KLy8gcGFzc2VzIG9wdF9pbiBjaGVjawphc3NlcnQKdHhuIFNlbmRlcgpieXRlY18zIC8vICJsYXN0IgpwdXNoYnl0ZXMgMHg0ZjcwNzQyMDQ5NmUyMDQxNzI2NzczIC8vICJPcHQgSW4gQXJncyIKYXBwX2xvY2FsX3B1dAppbnRjXzEgLy8gMQpyZXR1cm4KCi8vIGNsb3NlX291dApjbG9zZW91dF8yMjoKcHJvdG8gMCAwCmludGNfMSAvLyAxCnJldHVybgoKLy8gY2xvc2Vfb3V0X2JhcmUKY2xvc2VvdXRiYXJlXzIzOgpwcm90byAwIDAKaW50Y18xIC8vIDEKcmV0dXJuCgovLyBjbG9zZV9vdXRfYXJncwpjbG9zZW91dGFyZ3NfMjQ6CnByb3RvIDEgMApmcmFtZV9kaWcgLTEKZXh0cmFjdCAyIDAKYnl0ZWMgNCAvLyAiWWVzIgo9PQovLyBwYXNzZXMgY2xvc2Vfb3V0IGNoZWNrCmFzc2VydAppbnRjXzEgLy8gMQpyZXR1cm4KCi8vIGNhbGxfd2l0aF9wYXltZW50CmNhbGx3aXRocGF5bWVudF8yNToKcHJvdG8gMSAxCmJ5dGVjXzAgLy8gIiIKZnJhbWVfZGlnIC0xCmd0eG5zIEFtb3VudAppbnRjXzAgLy8gMAo+CmFzc2VydApwdXNoYnl0ZXMgMHgwMDEyNTA2MTc5NmQ2NTZlNzQyMDUzNzU2MzYzNjU3MzczNjY3NTZjIC8vIDB4MDAxMjUwNjE3OTZkNjU2ZTc0MjA1Mzc1NjM2MzY1NzM3MzY2NzU2YwpmcmFtZV9idXJ5IDAKcmV0c3ViCgovLyB2ZXJzaW9uX2Nhc3Rlcgp2ZXJzaW9uY2FzdGVyXzI2Ogpwcm90byAwIDAKaW50Y18wIC8vIDAKY2FsbHN1YiB2ZXJzaW9uXzAKZnJhbWVfYnVyeSAwCmJ5dGVjXzIgLy8gMHgxNTFmN2M3NQpmcmFtZV9kaWcgMAppdG9iCmNvbmNhdApsb2cKcmV0c3ViCgovLyByZWFkb25seV9jYXN0ZXIKcmVhZG9ubHljYXN0ZXJfMjc6CnByb3RvIDAgMAppbnRjXzAgLy8gMAp0eG5hIEFwcGxpY2F0aW9uQXJncyAxCmJ0b2kKZnJhbWVfYnVyeSAwCmZyYW1lX2RpZyAwCmNhbGxzdWIgcmVhZG9ubHlfMQpyZXRzdWIKCi8vIHNldF9ib3hfY2FzdGVyCnNldGJveGNhc3Rlcl8yODoKcHJvdG8gMCAwCmJ5dGVjXzAgLy8gIiIKZHVwCnR4bmEgQXBwbGljYXRpb25BcmdzIDEKZnJhbWVfYnVyeSAwCnR4bmEgQXBwbGljYXRpb25BcmdzIDIKZnJhbWVfYnVyeSAxCmZyYW1lX2RpZyAwCmZyYW1lX2RpZyAxCmNhbGxzdWIgc2V0Ym94XzIKcmV0c3ViCgovLyBnZXRfYm94X2Nhc3RlcgpnZXRib3hjYXN0ZXJfMjk6CnByb3RvIDAgMApieXRlY18wIC8vICIiCmR1cAp0eG5hIEFwcGxpY2F0aW9uQXJncyAxCmZyYW1lX2J1cnkgMQpmcmFtZV9kaWcgMQpjYWxsc3ViIGdldGJveF8zCmZyYW1lX2J1cnkgMApieXRlY18yIC8vIDB4MTUxZjdjNzUKZnJhbWVfZGlnIDAKY29uY2F0CmxvZwpyZXRzdWIKCi8vIGdldF9ib3hfcmVhZG9ubHlfY2FzdGVyCmdldGJveHJlYWRvbmx5Y2FzdGVyXzMwOgpwcm90byAwIDAKYnl0ZWNfMCAvLyAiIgpkdXAKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMQpmcmFtZV9idXJ5IDEKZnJhbWVfZGlnIDEKY2FsbHN1YiBnZXRib3hyZWFkb25seV80CmZyYW1lX2J1cnkgMApieXRlY18yIC8vIDB4MTUxZjdjNzUKZnJhbWVfZGlnIDAKY29uY2F0CmxvZwpyZXRzdWIKCi8vIHVwZGF0ZV9jYXN0ZXIKdXBkYXRlY2FzdGVyXzMxOgpwcm90byAwIDAKY2FsbHN1YiB1cGRhdGVfNQpyZXRzdWIKCi8vIHVwZGF0ZV9hcmdzX2Nhc3Rlcgp1cGRhdGVhcmdzY2FzdGVyXzMyOgpwcm90byAwIDAKYnl0ZWNfMCAvLyAiIgp0eG5hIEFwcGxpY2F0aW9uQXJncyAxCmZyYW1lX2J1cnkgMApmcmFtZV9kaWcgMApjYWxsc3ViIHVwZGF0ZWFyZ3NfNwpyZXRzdWIKCi8vIGRlbGV0ZV9jYXN0ZXIKZGVsZXRlY2FzdGVyXzMzOgpwcm90byAwIDAKY2FsbHN1YiBkZWxldGVfOApyZXRzdWIKCi8vIGRlbGV0ZV9hcmdzX2Nhc3RlcgpkZWxldGVhcmdzY2FzdGVyXzM0Ogpwcm90byAwIDAKYnl0ZWNfMCAvLyAiIgp0eG5hIEFwcGxpY2F0aW9uQXJncyAxCmZyYW1lX2J1cnkgMApmcmFtZV9kaWcgMApjYWxsc3ViIGRlbGV0ZWFyZ3NfMTAKcmV0c3ViCgovLyBjcmVhdGVfb3B0X2luX2Nhc3RlcgpjcmVhdGVvcHRpbmNhc3Rlcl8zNToKcHJvdG8gMCAwCmNhbGxzdWIgY3JlYXRlb3B0aW5fMTEKcmV0c3ViCgovLyB1cGRhdGVfZ3JlZXRpbmdfY2FzdGVyCnVwZGF0ZWdyZWV0aW5nY2FzdGVyXzM2Ogpwcm90byAwIDAKYnl0ZWNfMCAvLyAiIgp0eG5hIEFwcGxpY2F0aW9uQXJncyAxCmZyYW1lX2J1cnkgMApmcmFtZV9kaWcgMApjYWxsc3ViIHVwZGF0ZWdyZWV0aW5nXzEyCnJldHN1YgoKLy8gY3JlYXRlX2Nhc3RlcgpjcmVhdGVjYXN0ZXJfMzc6CnByb3RvIDAgMApjYWxsc3ViIGNyZWF0ZV8xNApyZXRzdWIKCi8vIGNyZWF0ZV9hcmdzX2Nhc3RlcgpjcmVhdGVhcmdzY2FzdGVyXzM4Ogpwcm90byAwIDAKYnl0ZWNfMCAvLyAiIgp0eG5hIEFwcGxpY2F0aW9uQXJncyAxCmZyYW1lX2J1cnkgMApmcmFtZV9kaWcgMApjYWxsc3ViIGNyZWF0ZWFyZ3NfMTUKcmV0c3ViCgovLyBoZWxsb19jYXN0ZXIKaGVsbG9jYXN0ZXJfMzk6CnByb3RvIDAgMApieXRlY18wIC8vICIiCmR1cAp0eG5hIEFwcGxpY2F0aW9uQXJncyAxCmZyYW1lX2J1cnkgMQpmcmFtZV9kaWcgMQpjYWxsc3ViIGhlbGxvXzE2CmZyYW1lX2J1cnkgMApieXRlY18yIC8vIDB4MTUxZjdjNzUKZnJhbWVfZGlnIDAKY29uY2F0CmxvZwpyZXRzdWIKCi8vIGhlbGxvX3JlbWVtYmVyX2Nhc3RlcgpoZWxsb3JlbWVtYmVyY2FzdGVyXzQwOgpwcm90byAwIDAKYnl0ZWNfMCAvLyAiIgpkdXAKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMQpmcmFtZV9idXJ5IDEKZnJhbWVfZGlnIDEKY2FsbHN1YiBoZWxsb3JlbWVtYmVyXzE3CmZyYW1lX2J1cnkgMApieXRlY18yIC8vIDB4MTUxZjdjNzUKZnJhbWVfZGlnIDAKY29uY2F0CmxvZwpyZXRzdWIKCi8vIGdldF9sYXN0X2Nhc3RlcgpnZXRsYXN0Y2FzdGVyXzQxOgpwcm90byAwIDAKYnl0ZWNfMCAvLyAiIgpjYWxsc3ViIGdldGxhc3RfMTgKZnJhbWVfYnVyeSAwCmJ5dGVjXzIgLy8gMHgxNTFmN2M3NQpmcmFtZV9kaWcgMApjb25jYXQKbG9nCnJldHN1YgoKLy8gb3B0X2luX2Nhc3RlcgpvcHRpbmNhc3Rlcl80MjoKcHJvdG8gMCAwCmNhbGxzdWIgb3B0aW5fMTkKcmV0c3ViCgovLyBvcHRfaW5fYXJnc19jYXN0ZXIKb3B0aW5hcmdzY2FzdGVyXzQzOgpwcm90byAwIDAKYnl0ZWNfMCAvLyAiIgp0eG5hIEFwcGxpY2F0aW9uQXJncyAxCmZyYW1lX2J1cnkgMApmcmFtZV9kaWcgMApjYWxsc3ViIG9wdGluYXJnc18yMQpyZXRzdWIKCi8vIGNsb3NlX291dF9jYXN0ZXIKY2xvc2VvdXRjYXN0ZXJfNDQ6CnByb3RvIDAgMApjYWxsc3ViIGNsb3Nlb3V0XzIyCnJldHN1YgoKLy8gY2xvc2Vfb3V0X2FyZ3NfY2FzdGVyCmNsb3Nlb3V0YXJnc2Nhc3Rlcl80NToKcHJvdG8gMCAwCmJ5dGVjXzAgLy8gIiIKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMQpmcmFtZV9idXJ5IDAKZnJhbWVfZGlnIDAKY2FsbHN1YiBjbG9zZW91dGFyZ3NfMjQKcmV0c3ViCgovLyBjYWxsX3dpdGhfcGF5bWVudF9jYXN0ZXIKY2FsbHdpdGhwYXltZW50Y2FzdGVyXzQ2Ogpwcm90byAwIDAKYnl0ZWNfMCAvLyAiIgppbnRjXzAgLy8gMAp0eG4gR3JvdXBJbmRleAppbnRjXzEgLy8gMQotCmZyYW1lX2J1cnkgMQpmcmFtZV9kaWcgMQpndHhucyBUeXBlRW51bQppbnRjXzEgLy8gcGF5Cj09CmFzc2VydApmcmFtZV9kaWcgMQpjYWxsc3ViIGNhbGx3aXRocGF5bWVudF8yNQpmcmFtZV9idXJ5IDAKYnl0ZWNfMiAvLyAweDE1MWY3Yzc1CmZyYW1lX2RpZyAwCmNvbmNhdApsb2cKcmV0c3Vi", + "clear": "I3ByYWdtYSB2ZXJzaW9uIDgKaW50Y2Jsb2NrIDEKY2FsbHN1YiBjbGVhcnN0YXRlXzAKaW50Y18wIC8vIDEKcmV0dXJuCgovLyBjbGVhcl9zdGF0ZQpjbGVhcnN0YXRlXzA6CnByb3RvIDAgMAppbnRjXzAgLy8gMQpyZXR1cm4=" + }, + "state": { + "global": { + "num_byte_slices": 1, + "num_uints": 0 + }, + "local": { + "num_byte_slices": 1, + "num_uints": 0 + } + }, + "schema": { + "global": { + "declared": { + "greeting": { + "type": "bytes", + "key": "greeting", + "descr": "" + } + }, + "reserved": {} + }, + "local": { + "declared": { + "last": { + "type": "bytes", + "key": "last", + "descr": "" + } + }, + "reserved": {} + } + }, + "contract": { + "name": "HelloWorldApp", + "methods": [ + { + "name": "version", + "args": [], + "returns": { + "type": "uint64" + } + }, + { + "name": "readonly", + "args": [ + { + "type": "uint64", + "name": "error" + } + ], + "returns": { + "type": "void" + } + }, + { + "name": "set_box", + "args": [ + { + "type": "byte[4]", + "name": "name" + }, + { + "type": "string", + "name": "value" + } + ], + "returns": { + "type": "void" + } + }, + { + "name": "get_box", + "args": [ + { + "type": "byte[4]", + "name": "name" + } + ], + "returns": { + "type": "string" + } + }, + { + "name": "get_box_readonly", + "args": [ + { + "type": "byte[4]", + "name": "name" + } + ], + "returns": { + "type": "string" + } + }, + { + "name": "update", + "args": [], + "returns": { + "type": "void" + } + }, + { + "name": "update_args", + "args": [ + { + "type": "string", + "name": "check" + } + ], + "returns": { + "type": "void" + } + }, + { + "name": "delete", + "args": [], + "returns": { + "type": "void" + } + }, + { + "name": "delete_args", + "args": [ + { + "type": "string", + "name": "check" + } + ], + "returns": { + "type": "void" + } + }, + { + "name": "create_opt_in", + "args": [], + "returns": { + "type": "void" + } + }, + { + "name": "update_greeting", + "args": [ + { + "type": "string", + "name": "greeting" + } + ], + "returns": { + "type": "void" + } + }, + { + "name": "create", + "args": [], + "returns": { + "type": "void" + } + }, + { + "name": "create_args", + "args": [ + { + "type": "string", + "name": "greeting" + } + ], + "returns": { + "type": "void" + } + }, + { + "name": "hello", + "args": [ + { + "type": "string", + "name": "name" + } + ], + "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" + } + }, + { + "name": "call_with_payment", + "args": [ + { + "type": "pay", + "name": "payment" + } + ], + "returns": { + "type": "string" + } + } + ], + "networks": {} + }, + "bare_call_config": { + "close_out": "CALL", + "delete_application": "CALL", + "no_op": "CREATE", + "opt_in": "CALL", + "update_application": "CALL" + } +} diff --git a/tests/artifacts/testing_app/arc32_app_spec.json b/tests/artifacts/testing_app/arc32_app_spec.json new file mode 100644 index 00000000..c308fc12 --- /dev/null +++ b/tests/artifacts/testing_app/arc32_app_spec.json @@ -0,0 +1,400 @@ +{ + "hints": { + "call_abi(string)string": { + "read_only": true, + "call_config": { + "no_op": "CALL" + } + }, + "call_abi_txn(pay,string)string": { + "call_config": { + "no_op": "CALL" + } + }, + "call_abi_foreign_refs()string": { + "read_only": true, + "call_config": { + "no_op": "CALL" + } + }, + "set_global(uint64,uint64,string,byte[4])void": { + "call_config": { + "no_op": "CALL" + } + }, + "set_local(uint64,uint64,string,byte[4])void": { + "call_config": { + "no_op": "CALL" + } + }, + "set_box(byte[4],string)void": { + "call_config": { + "no_op": "CALL" + } + }, + "error()void": { + "call_config": { + "no_op": "CALL" + } + }, + "create_abi(string)string": { + "call_config": { + "no_op": "CREATE" + } + }, + "update_abi(string)string": { + "call_config": { + "update_application": "CALL" + } + }, + "delete_abi(string)string": { + "call_config": { + "delete_application": "CALL" + } + }, + "opt_in()void": { + "call_config": { + "opt_in": "CALL" + } + }, + "default_value(string)string": { + "read_only": true, + "default_arguments": { + "arg_with_default": { + "source": "constant", + "data": "default value" + } + }, + "call_config": { + "no_op": "CALL" + } + }, + "default_value_from_abi(string)string": { + "read_only": true, + "default_arguments": { + "arg_with_default": { + "source": "abi-method", + "data": { + "name": "default_value", + "args": [ + { + "type": "string", + "name": "arg_with_default" + } + ], + "returns": { + "type": "string" + } + } + } + }, + "call_config": { + "no_op": "CALL" + } + }, + "default_value_from_global_state(uint64)uint64": { + "read_only": true, + "default_arguments": { + "arg_with_default": { + "source": "global-state", + "data": "int1" + } + }, + "call_config": { + "no_op": "CALL" + } + }, + "default_value_from_local_state(string)string": { + "read_only": true, + "default_arguments": { + "arg_with_default": { + "source": "local-state", + "data": "local_bytes1" + } + }, + "call_config": { + "no_op": "CALL" + } + } + }, + "source": { + "approval": "I3ByYWdtYSB2ZXJzaW9uIDgKaW50Y2Jsb2NrIDAgMSAxMCA1IFRNUExfVVBEQVRBQkxFIFRNUExfREVMRVRBQkxFCmJ5dGVjYmxvY2sgMHggMHgxNTFmN2M3NQp0eG4gTnVtQXBwQXJncwppbnRjXzAgLy8gMAo9PQpibnogbWFpbl9sMzIKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMApwdXNoYnl0ZXMgMHhmMTdlODBhNSAvLyAiY2FsbF9hYmkoc3RyaW5nKXN0cmluZyIKPT0KYm56IG1haW5fbDMxCnR4bmEgQXBwbGljYXRpb25BcmdzIDAKcHVzaGJ5dGVzIDB4MGE5MmE4MWUgLy8gImNhbGxfYWJpX3R4bihwYXksc3RyaW5nKXN0cmluZyIKPT0KYm56IG1haW5fbDMwCnR4bmEgQXBwbGljYXRpb25BcmdzIDAKcHVzaGJ5dGVzIDB4YWQ3NTYwMmMgLy8gImNhbGxfYWJpX2ZvcmVpZ25fcmVmcygpc3RyaW5nIgo9PQpibnogbWFpbl9sMjkKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMApwdXNoYnl0ZXMgMHhhNGNmOGRlYSAvLyAic2V0X2dsb2JhbCh1aW50NjQsdWludDY0LHN0cmluZyxieXRlWzRdKXZvaWQiCj09CmJueiBtYWluX2wyOAp0eG5hIEFwcGxpY2F0aW9uQXJncyAwCnB1c2hieXRlcyAweGNlYzI4MzRhIC8vICJzZXRfbG9jYWwodWludDY0LHVpbnQ2NCxzdHJpbmcsYnl0ZVs0XSl2b2lkIgo9PQpibnogbWFpbl9sMjcKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMApwdXNoYnl0ZXMgMHhhNGI0YTIzMCAvLyAic2V0X2JveChieXRlWzRdLHN0cmluZyl2b2lkIgo9PQpibnogbWFpbl9sMjYKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMApwdXNoYnl0ZXMgMHg0NGQwZGEwZCAvLyAiZXJyb3IoKXZvaWQiCj09CmJueiBtYWluX2wyNQp0eG5hIEFwcGxpY2F0aW9uQXJncyAwCnB1c2hieXRlcyAweDlkNTIzMDQwIC8vICJjcmVhdGVfYWJpKHN0cmluZylzdHJpbmciCj09CmJueiBtYWluX2wyNAp0eG5hIEFwcGxpY2F0aW9uQXJncyAwCnB1c2hieXRlcyAweDNjYTVjZWI3IC8vICJ1cGRhdGVfYWJpKHN0cmluZylzdHJpbmciCj09CmJueiBtYWluX2wyMwp0eG5hIEFwcGxpY2F0aW9uQXJncyAwCnB1c2hieXRlcyAweDI3MWI0ZWU5IC8vICJkZWxldGVfYWJpKHN0cmluZylzdHJpbmciCj09CmJueiBtYWluX2wyMgp0eG5hIEFwcGxpY2F0aW9uQXJncyAwCnB1c2hieXRlcyAweDMwYzZkNThhIC8vICJvcHRfaW4oKXZvaWQiCj09CmJueiBtYWluX2wyMQp0eG5hIEFwcGxpY2F0aW9uQXJncyAwCnB1c2hieXRlcyAweDU3NGI1NWM4IC8vICJkZWZhdWx0X3ZhbHVlKHN0cmluZylzdHJpbmciCj09CmJueiBtYWluX2wyMAp0eG5hIEFwcGxpY2F0aW9uQXJncyAwCnB1c2hieXRlcyAweDQ2ZDIxMWEzIC8vICJkZWZhdWx0X3ZhbHVlX2Zyb21fYWJpKHN0cmluZylzdHJpbmciCj09CmJueiBtYWluX2wxOQp0eG5hIEFwcGxpY2F0aW9uQXJncyAwCnB1c2hieXRlcyAweDBjZmNiYjAwIC8vICJkZWZhdWx0X3ZhbHVlX2Zyb21fZ2xvYmFsX3N0YXRlKHVpbnQ2NCl1aW50NjQiCj09CmJueiBtYWluX2wxOAp0eG5hIEFwcGxpY2F0aW9uQXJncyAwCnB1c2hieXRlcyAweGQwZjBiYWY4IC8vICJkZWZhdWx0X3ZhbHVlX2Zyb21fbG9jYWxfc3RhdGUoc3RyaW5nKXN0cmluZyIKPT0KYm56IG1haW5fbDE3CmVycgptYWluX2wxNzoKdHhuIE9uQ29tcGxldGlvbgppbnRjXzAgLy8gTm9PcAo9PQp0eG4gQXBwbGljYXRpb25JRAppbnRjXzAgLy8gMAohPQomJgphc3NlcnQKY2FsbHN1YiBkZWZhdWx0dmFsdWVmcm9tbG9jYWxzdGF0ZWNhc3Rlcl8zMwppbnRjXzEgLy8gMQpyZXR1cm4KbWFpbl9sMTg6CnR4biBPbkNvbXBsZXRpb24KaW50Y18wIC8vIE5vT3AKPT0KdHhuIEFwcGxpY2F0aW9uSUQKaW50Y18wIC8vIDAKIT0KJiYKYXNzZXJ0CmNhbGxzdWIgZGVmYXVsdHZhbHVlZnJvbWdsb2JhbHN0YXRlY2FzdGVyXzMyCmludGNfMSAvLyAxCnJldHVybgptYWluX2wxOToKdHhuIE9uQ29tcGxldGlvbgppbnRjXzAgLy8gTm9PcAo9PQp0eG4gQXBwbGljYXRpb25JRAppbnRjXzAgLy8gMAohPQomJgphc3NlcnQKY2FsbHN1YiBkZWZhdWx0dmFsdWVmcm9tYWJpY2FzdGVyXzMxCmludGNfMSAvLyAxCnJldHVybgptYWluX2wyMDoKdHhuIE9uQ29tcGxldGlvbgppbnRjXzAgLy8gTm9PcAo9PQp0eG4gQXBwbGljYXRpb25JRAppbnRjXzAgLy8gMAohPQomJgphc3NlcnQKY2FsbHN1YiBkZWZhdWx0dmFsdWVjYXN0ZXJfMzAKaW50Y18xIC8vIDEKcmV0dXJuCm1haW5fbDIxOgp0eG4gT25Db21wbGV0aW9uCmludGNfMSAvLyBPcHRJbgo9PQp0eG4gQXBwbGljYXRpb25JRAppbnRjXzAgLy8gMAohPQomJgphc3NlcnQKY2FsbHN1YiBvcHRpbmNhc3Rlcl8yOQppbnRjXzEgLy8gMQpyZXR1cm4KbWFpbl9sMjI6CnR4biBPbkNvbXBsZXRpb24KaW50Y18zIC8vIERlbGV0ZUFwcGxpY2F0aW9uCj09CnR4biBBcHBsaWNhdGlvbklECmludGNfMCAvLyAwCiE9CiYmCmFzc2VydApjYWxsc3ViIGRlbGV0ZWFiaWNhc3Rlcl8yOAppbnRjXzEgLy8gMQpyZXR1cm4KbWFpbl9sMjM6CnR4biBPbkNvbXBsZXRpb24KcHVzaGludCA0IC8vIFVwZGF0ZUFwcGxpY2F0aW9uCj09CnR4biBBcHBsaWNhdGlvbklECmludGNfMCAvLyAwCiE9CiYmCmFzc2VydApjYWxsc3ViIHVwZGF0ZWFiaWNhc3Rlcl8yNwppbnRjXzEgLy8gMQpyZXR1cm4KbWFpbl9sMjQ6CnR4biBPbkNvbXBsZXRpb24KaW50Y18wIC8vIE5vT3AKPT0KdHhuIEFwcGxpY2F0aW9uSUQKaW50Y18wIC8vIDAKPT0KJiYKYXNzZXJ0CmNhbGxzdWIgY3JlYXRlYWJpY2FzdGVyXzI2CmludGNfMSAvLyAxCnJldHVybgptYWluX2wyNToKdHhuIE9uQ29tcGxldGlvbgppbnRjXzAgLy8gTm9PcAo9PQp0eG4gQXBwbGljYXRpb25JRAppbnRjXzAgLy8gMAohPQomJgphc3NlcnQKY2FsbHN1YiBlcnJvcmNhc3Rlcl8yNQppbnRjXzEgLy8gMQpyZXR1cm4KbWFpbl9sMjY6CnR4biBPbkNvbXBsZXRpb24KaW50Y18wIC8vIE5vT3AKPT0KdHhuIEFwcGxpY2F0aW9uSUQKaW50Y18wIC8vIDAKIT0KJiYKYXNzZXJ0CmNhbGxzdWIgc2V0Ym94Y2FzdGVyXzI0CmludGNfMSAvLyAxCnJldHVybgptYWluX2wyNzoKdHhuIE9uQ29tcGxldGlvbgppbnRjXzAgLy8gTm9PcAo9PQp0eG4gQXBwbGljYXRpb25JRAppbnRjXzAgLy8gMAohPQomJgphc3NlcnQKY2FsbHN1YiBzZXRsb2NhbGNhc3Rlcl8yMwppbnRjXzEgLy8gMQpyZXR1cm4KbWFpbl9sMjg6CnR4biBPbkNvbXBsZXRpb24KaW50Y18wIC8vIE5vT3AKPT0KdHhuIEFwcGxpY2F0aW9uSUQKaW50Y18wIC8vIDAKIT0KJiYKYXNzZXJ0CmNhbGxzdWIgc2V0Z2xvYmFsY2FzdGVyXzIyCmludGNfMSAvLyAxCnJldHVybgptYWluX2wyOToKdHhuIE9uQ29tcGxldGlvbgppbnRjXzAgLy8gTm9PcAo9PQp0eG4gQXBwbGljYXRpb25JRAppbnRjXzAgLy8gMAohPQomJgphc3NlcnQKY2FsbHN1YiBjYWxsYWJpZm9yZWlnbnJlZnNjYXN0ZXJfMjEKaW50Y18xIC8vIDEKcmV0dXJuCm1haW5fbDMwOgp0eG4gT25Db21wbGV0aW9uCmludGNfMCAvLyBOb09wCj09CnR4biBBcHBsaWNhdGlvbklECmludGNfMCAvLyAwCiE9CiYmCmFzc2VydApjYWxsc3ViIGNhbGxhYml0eG5jYXN0ZXJfMjAKaW50Y18xIC8vIDEKcmV0dXJuCm1haW5fbDMxOgp0eG4gT25Db21wbGV0aW9uCmludGNfMCAvLyBOb09wCj09CnR4biBBcHBsaWNhdGlvbklECmludGNfMCAvLyAwCiE9CiYmCmFzc2VydApjYWxsc3ViIGNhbGxhYmljYXN0ZXJfMTkKaW50Y18xIC8vIDEKcmV0dXJuCm1haW5fbDMyOgp0eG4gT25Db21wbGV0aW9uCmludGNfMCAvLyBOb09wCj09CmJueiBtYWluX2w0MAp0eG4gT25Db21wbGV0aW9uCmludGNfMSAvLyBPcHRJbgo9PQpibnogbWFpbl9sMzkKdHhuIE9uQ29tcGxldGlvbgpwdXNoaW50IDQgLy8gVXBkYXRlQXBwbGljYXRpb24KPT0KYm56IG1haW5fbDM4CnR4biBPbkNvbXBsZXRpb24KaW50Y18zIC8vIERlbGV0ZUFwcGxpY2F0aW9uCj09CmJueiBtYWluX2wzNwplcnIKbWFpbl9sMzc6CnR4biBBcHBsaWNhdGlvbklECmludGNfMCAvLyAwCiE9CmFzc2VydApjYWxsc3ViIGRlbGV0ZV8xMgppbnRjXzEgLy8gMQpyZXR1cm4KbWFpbl9sMzg6CnR4biBBcHBsaWNhdGlvbklECmludGNfMCAvLyAwCiE9CmFzc2VydApjYWxsc3ViIHVwZGF0ZV8xMAppbnRjXzEgLy8gMQpyZXR1cm4KbWFpbl9sMzk6CnR4biBBcHBsaWNhdGlvbklECmludGNfMCAvLyAwCj09CmFzc2VydApjYWxsc3ViIGNyZWF0ZV84CmludGNfMSAvLyAxCnJldHVybgptYWluX2w0MDoKdHhuIEFwcGxpY2F0aW9uSUQKaW50Y18wIC8vIDAKPT0KYXNzZXJ0CmNhbGxzdWIgY3JlYXRlXzgKaW50Y18xIC8vIDEKcmV0dXJuCgovLyBjYWxsX2FiaQpjYWxsYWJpXzA6CnByb3RvIDEgMQpieXRlY18wIC8vICIiCnB1c2hieXRlcyAweDQ4NjU2YzZjNmYyYzIwIC8vICJIZWxsbywgIgpmcmFtZV9kaWcgLTEKZXh0cmFjdCAyIDAKY29uY2F0CmZyYW1lX2J1cnkgMApmcmFtZV9kaWcgMApsZW4KaXRvYgpleHRyYWN0IDYgMApmcmFtZV9kaWcgMApjb25jYXQKZnJhbWVfYnVyeSAwCnJldHN1YgoKLy8gaXRvYQppdG9hXzE6CnByb3RvIDEgMQpmcmFtZV9kaWcgLTEKaW50Y18wIC8vIDAKPT0KYm56IGl0b2FfMV9sNQpmcmFtZV9kaWcgLTEKaW50Y18yIC8vIDEwCi8KaW50Y18wIC8vIDAKPgpibnogaXRvYV8xX2w0CmJ5dGVjXzAgLy8gIiIKaXRvYV8xX2wzOgpwdXNoYnl0ZXMgMHgzMDMxMzIzMzM0MzUzNjM3MzgzOSAvLyAiMDEyMzQ1Njc4OSIKZnJhbWVfZGlnIC0xCmludGNfMiAvLyAxMAolCmludGNfMSAvLyAxCmV4dHJhY3QzCmNvbmNhdApiIGl0b2FfMV9sNgppdG9hXzFfbDQ6CmZyYW1lX2RpZyAtMQppbnRjXzIgLy8gMTAKLwpjYWxsc3ViIGl0b2FfMQpiIGl0b2FfMV9sMwppdG9hXzFfbDU6CnB1c2hieXRlcyAweDMwIC8vICIwIgppdG9hXzFfbDY6CnJldHN1YgoKLy8gY2FsbF9hYmlfdHhuCmNhbGxhYml0eG5fMjoKcHJvdG8gMiAxCmJ5dGVjXzAgLy8gIiIKcHVzaGJ5dGVzIDB4NTM2NTZlNzQyMCAvLyAiU2VudCAiCmZyYW1lX2RpZyAtMgpndHhucyBBbW91bnQKY2FsbHN1YiBpdG9hXzEKY29uY2F0CnB1c2hieXRlcyAweDJlMjAgLy8gIi4gIgpjb25jYXQKZnJhbWVfZGlnIC0xCmV4dHJhY3QgMiAwCmNvbmNhdApmcmFtZV9idXJ5IDAKZnJhbWVfZGlnIDAKbGVuCml0b2IKZXh0cmFjdCA2IDAKZnJhbWVfZGlnIDAKY29uY2F0CmZyYW1lX2J1cnkgMApyZXRzdWIKCi8vIGNhbGxfYWJpX2ZvcmVpZ25fcmVmcwpjYWxsYWJpZm9yZWlnbnJlZnNfMzoKcHJvdG8gMCAxCmJ5dGVjXzAgLy8gIiIKcHVzaGJ5dGVzIDB4NDE3MDcwM2EyMCAvLyAiQXBwOiAiCnR4bmEgQXBwbGljYXRpb25zIDEKY2FsbHN1YiBpdG9hXzEKY29uY2F0CnB1c2hieXRlcyAweDJjMjA0MTczNzM2NTc0M2EyMCAvLyAiLCBBc3NldDogIgpjb25jYXQKdHhuYSBBc3NldHMgMApjYWxsc3ViIGl0b2FfMQpjb25jYXQKcHVzaGJ5dGVzIDB4MmMyMDQxNjM2MzZmNzU2ZTc0M2EyMCAvLyAiLCBBY2NvdW50OiAiCmNvbmNhdAp0eG5hIEFjY291bnRzIDAKaW50Y18wIC8vIDAKZ2V0Ynl0ZQpjYWxsc3ViIGl0b2FfMQpjb25jYXQKcHVzaGJ5dGVzIDB4M2EgLy8gIjoiCmNvbmNhdAp0eG5hIEFjY291bnRzIDAKaW50Y18xIC8vIDEKZ2V0Ynl0ZQpjYWxsc3ViIGl0b2FfMQpjb25jYXQKZnJhbWVfYnVyeSAwCmZyYW1lX2RpZyAwCmxlbgppdG9iCmV4dHJhY3QgNiAwCmZyYW1lX2RpZyAwCmNvbmNhdApmcmFtZV9idXJ5IDAKcmV0c3ViCgovLyBzZXRfZ2xvYmFsCnNldGdsb2JhbF80Ogpwcm90byA0IDAKcHVzaGJ5dGVzIDB4Njk2ZTc0MzEgLy8gImludDEiCmZyYW1lX2RpZyAtNAphcHBfZ2xvYmFsX3B1dApwdXNoYnl0ZXMgMHg2OTZlNzQzMiAvLyAiaW50MiIKZnJhbWVfZGlnIC0zCmFwcF9nbG9iYWxfcHV0CnB1c2hieXRlcyAweDYyNzk3NDY1NzMzMSAvLyAiYnl0ZXMxIgpmcmFtZV9kaWcgLTIKZXh0cmFjdCAyIDAKYXBwX2dsb2JhbF9wdXQKcHVzaGJ5dGVzIDB4NjI3OTc0NjU3MzMyIC8vICJieXRlczIiCmZyYW1lX2RpZyAtMQphcHBfZ2xvYmFsX3B1dApyZXRzdWIKCi8vIHNldF9sb2NhbApzZXRsb2NhbF81Ogpwcm90byA0IDAKdHhuIFNlbmRlcgpwdXNoYnl0ZXMgMHg2YzZmNjM2MTZjNWY2OTZlNzQzMSAvLyAibG9jYWxfaW50MSIKZnJhbWVfZGlnIC00CmFwcF9sb2NhbF9wdXQKdHhuIFNlbmRlcgpwdXNoYnl0ZXMgMHg2YzZmNjM2MTZjNWY2OTZlNzQzMiAvLyAibG9jYWxfaW50MiIKZnJhbWVfZGlnIC0zCmFwcF9sb2NhbF9wdXQKdHhuIFNlbmRlcgpwdXNoYnl0ZXMgMHg2YzZmNjM2MTZjNWY2Mjc5NzQ2NTczMzEgLy8gImxvY2FsX2J5dGVzMSIKZnJhbWVfZGlnIC0yCmV4dHJhY3QgMiAwCmFwcF9sb2NhbF9wdXQKdHhuIFNlbmRlcgpwdXNoYnl0ZXMgMHg2YzZmNjM2MTZjNWY2Mjc5NzQ2NTczMzIgLy8gImxvY2FsX2J5dGVzMiIKZnJhbWVfZGlnIC0xCmFwcF9sb2NhbF9wdXQKcmV0c3ViCgovLyBzZXRfYm94CnNldGJveF82Ogpwcm90byAyIDAKZnJhbWVfZGlnIC0yCmJveF9kZWwKcG9wCmZyYW1lX2RpZyAtMgpmcmFtZV9kaWcgLTEKZXh0cmFjdCAyIDAKYm94X3B1dApyZXRzdWIKCi8vIGVycm9yCmVycm9yXzc6CnByb3RvIDAgMAppbnRjXzAgLy8gMAovLyBEZWxpYmVyYXRlIGVycm9yCmFzc2VydApyZXRzdWIKCi8vIGNyZWF0ZQpjcmVhdGVfODoKcHJvdG8gMCAwCnR4biBTZW5kZXIKZ2xvYmFsIENyZWF0b3JBZGRyZXNzCj09Ci8vIHVuYXV0aG9yaXplZAphc3NlcnQKcHVzaGJ5dGVzIDB4NzY2MTZjNzU2NSAvLyAidmFsdWUiCnB1c2hpbnQgVE1QTF9WQUxVRSAvLyBUTVBMX1ZBTFVFCmFwcF9nbG9iYWxfcHV0CnJldHN1YgoKLy8gY3JlYXRlX2FiaQpjcmVhdGVhYmlfOToKcHJvdG8gMSAxCmJ5dGVjXzAgLy8gIiIKdHhuIFNlbmRlcgpnbG9iYWwgQ3JlYXRvckFkZHJlc3MKPT0KLy8gdW5hdXRob3JpemVkCmFzc2VydApmcmFtZV9kaWcgLTEKZXh0cmFjdCAyIDAKZnJhbWVfYnVyeSAwCmZyYW1lX2RpZyAwCmxlbgppdG9iCmV4dHJhY3QgNiAwCmZyYW1lX2RpZyAwCmNvbmNhdApmcmFtZV9idXJ5IDAKcmV0c3ViCgovLyB1cGRhdGUKdXBkYXRlXzEwOgpwcm90byAwIDAKdHhuIFNlbmRlcgpnbG9iYWwgQ3JlYXRvckFkZHJlc3MKPT0KLy8gdW5hdXRob3JpemVkCmFzc2VydAppbnRjIDQgLy8gVE1QTF9VUERBVEFCTEUKLy8gQ2hlY2sgYXBwIGlzIHVwZGF0YWJsZQphc3NlcnQKcmV0c3ViCgovLyB1cGRhdGVfYWJpCnVwZGF0ZWFiaV8xMToKcHJvdG8gMSAxCmJ5dGVjXzAgLy8gIiIKdHhuIFNlbmRlcgpnbG9iYWwgQ3JlYXRvckFkZHJlc3MKPT0KLy8gdW5hdXRob3JpemVkCmFzc2VydAppbnRjIDQgLy8gVE1QTF9VUERBVEFCTEUKLy8gQ2hlY2sgYXBwIGlzIHVwZGF0YWJsZQphc3NlcnQKZnJhbWVfZGlnIC0xCmV4dHJhY3QgMiAwCmZyYW1lX2J1cnkgMApmcmFtZV9kaWcgMApsZW4KaXRvYgpleHRyYWN0IDYgMApmcmFtZV9kaWcgMApjb25jYXQKZnJhbWVfYnVyeSAwCnJldHN1YgoKLy8gZGVsZXRlCmRlbGV0ZV8xMjoKcHJvdG8gMCAwCnR4biBTZW5kZXIKZ2xvYmFsIENyZWF0b3JBZGRyZXNzCj09Ci8vIHVuYXV0aG9yaXplZAphc3NlcnQKaW50YyA1IC8vIFRNUExfREVMRVRBQkxFCi8vIENoZWNrIGFwcCBpcyBkZWxldGFibGUKYXNzZXJ0CnJldHN1YgoKLy8gZGVsZXRlX2FiaQpkZWxldGVhYmlfMTM6CnByb3RvIDEgMQpieXRlY18wIC8vICIiCnR4biBTZW5kZXIKZ2xvYmFsIENyZWF0b3JBZGRyZXNzCj09Ci8vIHVuYXV0aG9yaXplZAphc3NlcnQKaW50YyA1IC8vIFRNUExfREVMRVRBQkxFCi8vIENoZWNrIGFwcCBpcyBkZWxldGFibGUKYXNzZXJ0CmZyYW1lX2RpZyAtMQpleHRyYWN0IDIgMApmcmFtZV9idXJ5IDAKZnJhbWVfZGlnIDAKbGVuCml0b2IKZXh0cmFjdCA2IDAKZnJhbWVfZGlnIDAKY29uY2F0CmZyYW1lX2J1cnkgMApyZXRzdWIKCi8vIG9wdF9pbgpvcHRpbl8xNDoKcHJvdG8gMCAwCmludGNfMSAvLyAxCnJldHVybgoKLy8gZGVmYXVsdF92YWx1ZQpkZWZhdWx0dmFsdWVfMTU6CnByb3RvIDEgMQpieXRlY18wIC8vICIiCmZyYW1lX2RpZyAtMQpleHRyYWN0IDIgMApmcmFtZV9idXJ5IDAKZnJhbWVfZGlnIDAKbGVuCml0b2IKZXh0cmFjdCA2IDAKZnJhbWVfZGlnIDAKY29uY2F0CmZyYW1lX2J1cnkgMApyZXRzdWIKCi8vIGRlZmF1bHRfdmFsdWVfZnJvbV9hYmkKZGVmYXVsdHZhbHVlZnJvbWFiaV8xNjoKcHJvdG8gMSAxCmJ5dGVjXzAgLy8gIiIKcHVzaGJ5dGVzIDB4NDE0MjQ5MmMyMCAvLyAiQUJJLCAiCmZyYW1lX2RpZyAtMQpleHRyYWN0IDIgMApjb25jYXQKZnJhbWVfYnVyeSAwCmZyYW1lX2RpZyAwCmxlbgppdG9iCmV4dHJhY3QgNiAwCmZyYW1lX2RpZyAwCmNvbmNhdApmcmFtZV9idXJ5IDAKcmV0c3ViCgovLyBkZWZhdWx0X3ZhbHVlX2Zyb21fZ2xvYmFsX3N0YXRlCmRlZmF1bHR2YWx1ZWZyb21nbG9iYWxzdGF0ZV8xNzoKcHJvdG8gMSAxCmludGNfMCAvLyAwCmZyYW1lX2RpZyAtMQpmcmFtZV9idXJ5IDAKcmV0c3ViCgovLyBkZWZhdWx0X3ZhbHVlX2Zyb21fbG9jYWxfc3RhdGUKZGVmYXVsdHZhbHVlZnJvbWxvY2Fsc3RhdGVfMTg6CnByb3RvIDEgMQpieXRlY18wIC8vICIiCnB1c2hieXRlcyAweDRjNmY2MzYxNmMyMDczNzQ2MTc0NjUyYzIwIC8vICJMb2NhbCBzdGF0ZSwgIgpmcmFtZV9kaWcgLTEKZXh0cmFjdCAyIDAKY29uY2F0CmZyYW1lX2J1cnkgMApmcmFtZV9kaWcgMApsZW4KaXRvYgpleHRyYWN0IDYgMApmcmFtZV9kaWcgMApjb25jYXQKZnJhbWVfYnVyeSAwCnJldHN1YgoKLy8gY2FsbF9hYmlfY2FzdGVyCmNhbGxhYmljYXN0ZXJfMTk6CnByb3RvIDAgMApieXRlY18wIC8vICIiCmR1cAp0eG5hIEFwcGxpY2F0aW9uQXJncyAxCmZyYW1lX2J1cnkgMQpmcmFtZV9kaWcgMQpjYWxsc3ViIGNhbGxhYmlfMApmcmFtZV9idXJ5IDAKYnl0ZWNfMSAvLyAweDE1MWY3Yzc1CmZyYW1lX2RpZyAwCmNvbmNhdApsb2cKcmV0c3ViCgovLyBjYWxsX2FiaV90eG5fY2FzdGVyCmNhbGxhYml0eG5jYXN0ZXJfMjA6CnByb3RvIDAgMApieXRlY18wIC8vICIiCmludGNfMCAvLyAwCmJ5dGVjXzAgLy8gIiIKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMQpmcmFtZV9idXJ5IDIKdHhuIEdyb3VwSW5kZXgKaW50Y18xIC8vIDEKLQpmcmFtZV9idXJ5IDEKZnJhbWVfZGlnIDEKZ3R4bnMgVHlwZUVudW0KaW50Y18xIC8vIHBheQo9PQphc3NlcnQKZnJhbWVfZGlnIDEKZnJhbWVfZGlnIDIKY2FsbHN1YiBjYWxsYWJpdHhuXzIKZnJhbWVfYnVyeSAwCmJ5dGVjXzEgLy8gMHgxNTFmN2M3NQpmcmFtZV9kaWcgMApjb25jYXQKbG9nCnJldHN1YgoKLy8gY2FsbF9hYmlfZm9yZWlnbl9yZWZzX2Nhc3RlcgpjYWxsYWJpZm9yZWlnbnJlZnNjYXN0ZXJfMjE6CnByb3RvIDAgMApieXRlY18wIC8vICIiCmNhbGxzdWIgY2FsbGFiaWZvcmVpZ25yZWZzXzMKZnJhbWVfYnVyeSAwCmJ5dGVjXzEgLy8gMHgxNTFmN2M3NQpmcmFtZV9kaWcgMApjb25jYXQKbG9nCnJldHN1YgoKLy8gc2V0X2dsb2JhbF9jYXN0ZXIKc2V0Z2xvYmFsY2FzdGVyXzIyOgpwcm90byAwIDAKaW50Y18wIC8vIDAKZHVwCmJ5dGVjXzAgLy8gIiIKZHVwCnR4bmEgQXBwbGljYXRpb25BcmdzIDEKYnRvaQpmcmFtZV9idXJ5IDAKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMgpidG9pCmZyYW1lX2J1cnkgMQp0eG5hIEFwcGxpY2F0aW9uQXJncyAzCmZyYW1lX2J1cnkgMgp0eG5hIEFwcGxpY2F0aW9uQXJncyA0CmZyYW1lX2J1cnkgMwpmcmFtZV9kaWcgMApmcmFtZV9kaWcgMQpmcmFtZV9kaWcgMgpmcmFtZV9kaWcgMwpjYWxsc3ViIHNldGdsb2JhbF80CnJldHN1YgoKLy8gc2V0X2xvY2FsX2Nhc3RlcgpzZXRsb2NhbGNhc3Rlcl8yMzoKcHJvdG8gMCAwCmludGNfMCAvLyAwCmR1cApieXRlY18wIC8vICIiCmR1cAp0eG5hIEFwcGxpY2F0aW9uQXJncyAxCmJ0b2kKZnJhbWVfYnVyeSAwCnR4bmEgQXBwbGljYXRpb25BcmdzIDIKYnRvaQpmcmFtZV9idXJ5IDEKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMwpmcmFtZV9idXJ5IDIKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgNApmcmFtZV9idXJ5IDMKZnJhbWVfZGlnIDAKZnJhbWVfZGlnIDEKZnJhbWVfZGlnIDIKZnJhbWVfZGlnIDMKY2FsbHN1YiBzZXRsb2NhbF81CnJldHN1YgoKLy8gc2V0X2JveF9jYXN0ZXIKc2V0Ym94Y2FzdGVyXzI0Ogpwcm90byAwIDAKYnl0ZWNfMCAvLyAiIgpkdXAKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMQpmcmFtZV9idXJ5IDAKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMgpmcmFtZV9idXJ5IDEKZnJhbWVfZGlnIDAKZnJhbWVfZGlnIDEKY2FsbHN1YiBzZXRib3hfNgpyZXRzdWIKCi8vIGVycm9yX2Nhc3RlcgplcnJvcmNhc3Rlcl8yNToKcHJvdG8gMCAwCmNhbGxzdWIgZXJyb3JfNwpyZXRzdWIKCi8vIGNyZWF0ZV9hYmlfY2FzdGVyCmNyZWF0ZWFiaWNhc3Rlcl8yNjoKcHJvdG8gMCAwCmJ5dGVjXzAgLy8gIiIKZHVwCnR4bmEgQXBwbGljYXRpb25BcmdzIDEKZnJhbWVfYnVyeSAxCmZyYW1lX2RpZyAxCmNhbGxzdWIgY3JlYXRlYWJpXzkKZnJhbWVfYnVyeSAwCmJ5dGVjXzEgLy8gMHgxNTFmN2M3NQpmcmFtZV9kaWcgMApjb25jYXQKbG9nCnJldHN1YgoKLy8gdXBkYXRlX2FiaV9jYXN0ZXIKdXBkYXRlYWJpY2FzdGVyXzI3Ogpwcm90byAwIDAKYnl0ZWNfMCAvLyAiIgpkdXAKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMQpmcmFtZV9idXJ5IDEKZnJhbWVfZGlnIDEKY2FsbHN1YiB1cGRhdGVhYmlfMTEKZnJhbWVfYnVyeSAwCmJ5dGVjXzEgLy8gMHgxNTFmN2M3NQpmcmFtZV9kaWcgMApjb25jYXQKbG9nCnJldHN1YgoKLy8gZGVsZXRlX2FiaV9jYXN0ZXIKZGVsZXRlYWJpY2FzdGVyXzI4Ogpwcm90byAwIDAKYnl0ZWNfMCAvLyAiIgpkdXAKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMQpmcmFtZV9idXJ5IDEKZnJhbWVfZGlnIDEKY2FsbHN1YiBkZWxldGVhYmlfMTMKZnJhbWVfYnVyeSAwCmJ5dGVjXzEgLy8gMHgxNTFmN2M3NQpmcmFtZV9kaWcgMApjb25jYXQKbG9nCnJldHN1YgoKLy8gb3B0X2luX2Nhc3RlcgpvcHRpbmNhc3Rlcl8yOToKcHJvdG8gMCAwCmNhbGxzdWIgb3B0aW5fMTQKcmV0c3ViCgovLyBkZWZhdWx0X3ZhbHVlX2Nhc3RlcgpkZWZhdWx0dmFsdWVjYXN0ZXJfMzA6CnByb3RvIDAgMApieXRlY18wIC8vICIiCmR1cAp0eG5hIEFwcGxpY2F0aW9uQXJncyAxCmZyYW1lX2J1cnkgMQpmcmFtZV9kaWcgMQpjYWxsc3ViIGRlZmF1bHR2YWx1ZV8xNQpmcmFtZV9idXJ5IDAKYnl0ZWNfMSAvLyAweDE1MWY3Yzc1CmZyYW1lX2RpZyAwCmNvbmNhdApsb2cKcmV0c3ViCgovLyBkZWZhdWx0X3ZhbHVlX2Zyb21fYWJpX2Nhc3RlcgpkZWZhdWx0dmFsdWVmcm9tYWJpY2FzdGVyXzMxOgpwcm90byAwIDAKYnl0ZWNfMCAvLyAiIgpkdXAKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMQpmcmFtZV9idXJ5IDEKZnJhbWVfZGlnIDEKY2FsbHN1YiBkZWZhdWx0dmFsdWVmcm9tYWJpXzE2CmZyYW1lX2J1cnkgMApieXRlY18xIC8vIDB4MTUxZjdjNzUKZnJhbWVfZGlnIDAKY29uY2F0CmxvZwpyZXRzdWIKCi8vIGRlZmF1bHRfdmFsdWVfZnJvbV9nbG9iYWxfc3RhdGVfY2FzdGVyCmRlZmF1bHR2YWx1ZWZyb21nbG9iYWxzdGF0ZWNhc3Rlcl8zMjoKcHJvdG8gMCAwCmludGNfMCAvLyAwCmR1cAp0eG5hIEFwcGxpY2F0aW9uQXJncyAxCmJ0b2kKZnJhbWVfYnVyeSAxCmZyYW1lX2RpZyAxCmNhbGxzdWIgZGVmYXVsdHZhbHVlZnJvbWdsb2JhbHN0YXRlXzE3CmZyYW1lX2J1cnkgMApieXRlY18xIC8vIDB4MTUxZjdjNzUKZnJhbWVfZGlnIDAKaXRvYgpjb25jYXQKbG9nCnJldHN1YgoKLy8gZGVmYXVsdF92YWx1ZV9mcm9tX2xvY2FsX3N0YXRlX2Nhc3RlcgpkZWZhdWx0dmFsdWVmcm9tbG9jYWxzdGF0ZWNhc3Rlcl8zMzoKcHJvdG8gMCAwCmJ5dGVjXzAgLy8gIiIKZHVwCnR4bmEgQXBwbGljYXRpb25BcmdzIDEKZnJhbWVfYnVyeSAxCmZyYW1lX2RpZyAxCmNhbGxzdWIgZGVmYXVsdHZhbHVlZnJvbWxvY2Fsc3RhdGVfMTgKZnJhbWVfYnVyeSAwCmJ5dGVjXzEgLy8gMHgxNTFmN2M3NQpmcmFtZV9kaWcgMApjb25jYXQKbG9nCnJldHN1Yg==", + "clear": "I3ByYWdtYSB2ZXJzaW9uIDgKcHVzaGludCAwIC8vIDAKcmV0dXJu" + }, + "state": { + "global": { + "num_byte_slices": 2, + "num_uints": 3 + }, + "local": { + "num_byte_slices": 2, + "num_uints": 2 + } + }, + "schema": { + "global": { + "declared": { + "bytes1": { + "type": "bytes", + "key": "bytes1", + "descr": "" + }, + "bytes2": { + "type": "bytes", + "key": "bytes2", + "descr": "" + }, + "int1": { + "type": "uint64", + "key": "int1", + "descr": "" + }, + "int2": { + "type": "uint64", + "key": "int2", + "descr": "" + }, + "value": { + "type": "uint64", + "key": "value", + "descr": "" + } + }, + "reserved": {} + }, + "local": { + "declared": { + "local_bytes1": { + "type": "bytes", + "key": "local_bytes1", + "descr": "" + }, + "local_bytes2": { + "type": "bytes", + "key": "local_bytes2", + "descr": "" + }, + "local_int1": { + "type": "uint64", + "key": "local_int1", + "descr": "" + }, + "local_int2": { + "type": "uint64", + "key": "local_int2", + "descr": "" + } + }, + "reserved": {} + } + }, + "contract": { + "name": "TestingApp", + "methods": [ + { + "name": "call_abi", + "args": [ + { + "type": "string", + "name": "value" + } + ], + "returns": { + "type": "string" + } + }, + { + "name": "call_abi_txn", + "args": [ + { + "type": "pay", + "name": "txn" + }, + { + "type": "string", + "name": "value" + } + ], + "returns": { + "type": "string" + } + }, + { + "name": "call_abi_foreign_refs", + "args": [], + "returns": { + "type": "string" + } + }, + { + "name": "set_global", + "args": [ + { + "type": "uint64", + "name": "int1" + }, + { + "type": "uint64", + "name": "int2" + }, + { + "type": "string", + "name": "bytes1" + }, + { + "type": "byte[4]", + "name": "bytes2" + } + ], + "returns": { + "type": "void" + } + }, + { + "name": "set_local", + "args": [ + { + "type": "uint64", + "name": "int1" + }, + { + "type": "uint64", + "name": "int2" + }, + { + "type": "string", + "name": "bytes1" + }, + { + "type": "byte[4]", + "name": "bytes2" + } + ], + "returns": { + "type": "void" + } + }, + { + "name": "set_box", + "args": [ + { + "type": "byte[4]", + "name": "name" + }, + { + "type": "string", + "name": "value" + } + ], + "returns": { + "type": "void" + } + }, + { + "name": "error", + "args": [], + "returns": { + "type": "void" + } + }, + { + "name": "create_abi", + "args": [ + { + "type": "string", + "name": "input" + } + ], + "returns": { + "type": "string" + } + }, + { + "name": "update_abi", + "args": [ + { + "type": "string", + "name": "input" + } + ], + "returns": { + "type": "string" + } + }, + { + "name": "delete_abi", + "args": [ + { + "type": "string", + "name": "input" + } + ], + "returns": { + "type": "string" + } + }, + { + "name": "opt_in", + "args": [], + "returns": { + "type": "void" + } + }, + { + "name": "default_value", + "args": [ + { + "type": "string", + "name": "arg_with_default" + } + ], + "returns": { + "type": "string" + } + }, + { + "name": "default_value_from_abi", + "args": [ + { + "type": "string", + "name": "arg_with_default" + } + ], + "returns": { + "type": "string" + } + }, + { + "name": "default_value_from_global_state", + "args": [ + { + "type": "uint64", + "name": "arg_with_default" + } + ], + "returns": { + "type": "uint64" + } + }, + { + "name": "default_value_from_local_state", + "args": [ + { + "type": "string", + "name": "arg_with_default" + } + ], + "returns": { + "type": "string" + } + } + ], + "networks": {} + }, + "bare_call_config": { + "delete_application": "CALL", + "no_op": "CREATE", + "opt_in": "CREATE", + "update_application": "CALL" + } +} diff --git a/tests/artifacts/testing_app/contract.py b/tests/artifacts/testing_app/contract.py new file mode 100644 index 00000000..95159cbc --- /dev/null +++ b/tests/artifacts/testing_app/contract.py @@ -0,0 +1,185 @@ +from typing import Literal + +import beaker +import pyteal as pt +from beaker.lib.storage import BoxMapping +from pyteal.ast import CallConfig, MethodConfig + +UPDATABLE_TEMPLATE_NAME = "TMPL_UPDATABLE" +DELETABLE_TEMPLATE_NAME = "TMPL_DELETABLE" + + +class BareCallAppState: + value = beaker.GlobalStateValue(stack_type=pt.TealType.uint64) + bytes1 = beaker.GlobalStateValue(stack_type=pt.TealType.bytes) + bytes2 = beaker.GlobalStateValue(stack_type=pt.TealType.bytes) + int1 = beaker.GlobalStateValue(stack_type=pt.TealType.uint64) + int2 = beaker.GlobalStateValue(stack_type=pt.TealType.uint64) + local_bytes1 = beaker.LocalStateValue(stack_type=pt.TealType.bytes) + local_bytes2 = beaker.LocalStateValue(stack_type=pt.TealType.bytes) + local_int1 = beaker.LocalStateValue(stack_type=pt.TealType.uint64) + local_int2 = beaker.LocalStateValue(stack_type=pt.TealType.uint64) + box = BoxMapping(pt.abi.StaticBytes[Literal[4]], pt.abi.String) + + +app = beaker.Application("TestingApp", state=BareCallAppState) + + +@app.external(read_only=True) +def call_abi(value: pt.abi.String, *, output: pt.abi.String) -> pt.Expr: + return output.set(pt.Concat(pt.Bytes("Hello, "), value.get())) + + +# https://github.com/algorand/pyteal-utils/blob/main/pytealutils/strings/string.py#L63 +@pt.Subroutine(pt.TealType.bytes) +def itoa(i: pt.Expr) -> pt.Expr: + """itoa converts an integer to the ascii byte string it represents""" + return pt.If( + i == pt.Int(0), + pt.Bytes("0"), + pt.Concat( + pt.If(i / pt.Int(10) > pt.Int(0), itoa(i / pt.Int(10)), pt.Bytes("")), + pt.Extract(pt.Bytes("0123456789"), i % pt.Int(10), pt.Int(1)), + ), + ) + + +@app.external() +def call_abi_txn(txn: pt.abi.PaymentTransaction, value: pt.abi.String, *, output: pt.abi.String) -> pt.Expr: + return output.set( + pt.Concat( + pt.Bytes("Sent "), + itoa(txn.get().amount()), + pt.Bytes(". "), + value.get(), + ) + ) + + +@app.external(read_only=True) +def call_abi_foreign_refs(*, output: pt.abi.String) -> pt.Expr: + return output.set( + pt.Concat( + pt.Bytes("App: "), + itoa(pt.Txn.applications[1]), + pt.Bytes(", Asset: "), + itoa(pt.Txn.assets[0]), + pt.Bytes(", Account: "), + itoa(pt.GetByte(pt.Txn.accounts[0], pt.Int(0))), + pt.Bytes(":"), + itoa(pt.GetByte(pt.Txn.accounts[0], pt.Int(1))), + ) + ) + + +@app.external() +def set_global( + int1: pt.abi.Uint64, int2: pt.abi.Uint64, bytes1: pt.abi.String, bytes2: pt.abi.StaticBytes[Literal[4]] +) -> pt.Expr: + return pt.Seq( + app.state.int1.set(int1.get()), + app.state.int2.set(int2.get()), + app.state.bytes1.set(bytes1.get()), + app.state.bytes2.set(bytes2.get()), + ) + + +@app.external() +def set_local( + int1: pt.abi.Uint64, int2: pt.abi.Uint64, bytes1: pt.abi.String, bytes2: pt.abi.StaticBytes[Literal[4]] +) -> pt.Expr: + return pt.Seq( + app.state.local_int1.set(int1.get()), + app.state.local_int2.set(int2.get()), + app.state.local_bytes1.set(bytes1.get()), + app.state.local_bytes2.set(bytes2.get()), + ) + + +@app.external() +def set_box(name: pt.abi.StaticBytes[Literal[4]], value: pt.abi.String) -> pt.Expr: + return app.state.box[name.get()].set(value.get()) + + +@app.external() +def error() -> pt.Expr: + return pt.Assert(pt.Int(0), comment="Deliberate error") + + +@app.external( + authorize=beaker.Authorize.only_creator(), + bare=True, + method_config=MethodConfig(no_op=CallConfig.CREATE, opt_in=CallConfig.CREATE), +) +def create() -> pt.Expr: + return app.state.value.set(pt.Tmpl.Int("TMPL_VALUE")) + + +@app.create(authorize=beaker.Authorize.only_creator()) +def create_abi(input: pt.abi.String, *, output: pt.abi.String) -> pt.Expr: + return output.set(input.get()) + + +@app.update(authorize=beaker.Authorize.only_creator(), bare=True) +def update() -> pt.Expr: + return pt.Assert(pt.Tmpl.Int(UPDATABLE_TEMPLATE_NAME), comment="Check app is updatable") + + +@app.update(authorize=beaker.Authorize.only_creator()) +def update_abi(input: pt.abi.String, *, output: pt.abi.String) -> pt.Expr: + return pt.Seq( + pt.Assert(pt.Tmpl.Int(UPDATABLE_TEMPLATE_NAME), comment="Check app is updatable"), output.set(input.get()) + ) + + +@app.delete(authorize=beaker.Authorize.only_creator(), bare=True) +def delete() -> pt.Expr: + return pt.Assert(pt.Tmpl.Int(DELETABLE_TEMPLATE_NAME), comment="Check app is deletable") + + +@app.delete(authorize=beaker.Authorize.only_creator()) +def delete_abi(input: pt.abi.String, *, output: pt.abi.String) -> pt.Expr: + return pt.Seq( + pt.Assert(pt.Tmpl.Int(DELETABLE_TEMPLATE_NAME), comment="Check app is deletable"), output.set(input.get()) + ) + + +@app.opt_in +def opt_in() -> pt.Expr: + return pt.Approve() + + +@app.external(read_only=True) +def default_value( + arg_with_default: pt.abi.String = "default value", + *, + output: pt.abi.String, # type: ignore[assignment] +) -> pt.Expr: + return output.set(arg_with_default.get()) + + +@app.external(read_only=True) +def default_value_from_abi( + arg_with_default: pt.abi.String = default_value, + *, + output: pt.abi.String, # type: ignore[assignment] +) -> pt.Expr: + return output.set(pt.Concat(pt.Bytes("ABI, "), arg_with_default.get())) + + +@app.external(read_only=True) +def default_value_from_global_state( + arg_with_default: pt.abi.Uint64 = BareCallAppState.int1, + *, + output: pt.abi.Uint64, # type: ignore[assignment] +) -> pt.Expr: + return output.set(arg_with_default.get()) + + +@app.external(read_only=True) +def default_value_from_local_state( + arg_with_default: pt.abi.String = BareCallAppState.local_bytes1, + *, + output: pt.abi.String, # type: ignore[assignment] +) -> pt.Expr: + return output.set(pt.Concat(pt.Bytes("Local state, "), arg_with_default.get())) diff --git a/tests/artifacts/testing_app/sources.teal.map.json b/tests/artifacts/testing_app/sources.teal.map.json new file mode 100644 index 00000000..9ee43398 --- /dev/null +++ b/tests/artifacts/testing_app/sources.teal.map.json @@ -0,0 +1,22 @@ +{ + "approvalSourceMap": { + "version": 3, + "sources": [ + "" + ], + "names": [], + "mappings": ";AACA;;;;;;;;AACA;;;;;;;;AACA;;AACA;AACA;AACA;;;AACA;;;AACA;;;;;;AACA;AACA;;;AACA;;;AACA;;;;;;AACA;AACA;;;AACA;;;AACA;;;;;;AACA;AACA;;;AACA;;;AACA;;;;;;AACA;AACA;;;AACA;;;AACA;;;;;;AACA;AACA;;;AACA;;;AACA;;;;;;AACA;AACA;;;AACA;;;AACA;;;;;;AACA;AACA;;;AACA;;;AACA;;;;;;AACA;AACA;;;AACA;;;AACA;;;;;;AACA;AACA;;;AACA;;;AACA;;;;;;AACA;AACA;;;AACA;;;AACA;;;;;;AACA;AACA;;;AACA;;;AACA;;;;;;AACA;AACA;;;AACA;;;AACA;;;;;;AACA;AACA;;;AACA;;;AACA;;;;;;AACA;AACA;;;AACA;;;AACA;;;;;;AACA;AACA;;;AACA;AAEA;;AACA;AACA;AACA;;AACA;AACA;AACA;AACA;AACA;;;AACA;AACA;AAEA;;AACA;AACA;AACA;;AACA;AACA;AACA;AACA;AACA;;;AACA;AACA;AAEA;;AACA;AACA;AACA;;AACA;AACA;AACA;AACA;AACA;;;AACA;AACA;AAEA;;AACA;AACA;AACA;;AACA;AACA;AACA;AACA;AACA;;;AACA;AACA;AAEA;;AACA;AACA;AACA;;AACA;AACA;AACA;AACA;AACA;;;AACA;AACA;AAEA;;AACA;AACA;AACA;;AACA;AACA;AACA;AACA;AACA;;;AACA;AACA;AAEA;;AACA;;AACA;AACA;;AACA;AACA;AACA;AACA;AACA;;;AACA;AACA;AAEA;;AACA;AACA;AACA;;AACA;AACA;AACA;AACA;AACA;;;AACA;AACA;AAEA;;AACA;AACA;AACA;;AACA;AACA;AACA;AACA;AACA;;;AACA;AACA;AAEA;;AACA;AACA;AACA;;AACA;AACA;AACA;AACA;AACA;;;AACA;AACA;AAEA;;AACA;AACA;AACA;;AACA;AACA;AACA;AACA;AACA;;;AACA;AACA;AAEA;;AACA;AACA;AACA;;AACA;AACA;AACA;AACA;AACA;;;AACA;AACA;AAEA;;AACA;AACA;AACA;;AACA;AACA;AACA;AACA;AACA;;;AACA;AACA;AAEA;;AACA;AACA;AACA;;AACA;AACA;AACA;AACA;AACA;;;AACA;AACA;AAEA;;AACA;AACA;AACA;;AACA;AACA;AACA;AACA;AACA;;;AACA;AACA;AAEA;;AACA;AACA;AACA;;;AACA;;AACA;AACA;AACA;;;AACA;;AACA;;AACA;AACA;;;AACA;;AACA;AACA;AACA;;;AACA;AAEA;;AACA;AACA;AACA;AACA;;;AACA;AACA;AAEA;;AACA;AACA;AACA;AACA;;;AACA;AACA;AAEA;;AACA;AACA;AACA;AACA;;;AACA;AACA;AAEA;;AACA;AACA;AACA;AACA;;;AACA;AACA;AAIA;;;AACA;AACA;;;;;;;;;AACA;;AACA;;;AACA;AACA;;AACA;;AACA;AACA;AACA;;;AACA;;AACA;AACA;;AACA;AAIA;;;AACA;;AACA;AACA;AACA;;;AACA;;AACA;AACA;AACA;AACA;AACA;;;AACA;AAEA;;;;;;;;;;;;AACA;;AACA;AACA;AACA;AACA;AACA;AACA;;;AAEA;;AACA;AACA;AACA;;;AACA;;;AAEA;;;AAEA;AAIA;;;AACA;AACA;;;;;;;AACA;;AACA;;AACA;;;AACA;AACA;;;;AACA;AACA;;AACA;;;AACA;AACA;;AACA;;AACA;AACA;AACA;;;AACA;;AACA;AACA;;AACA;AAIA;;;AACA;AACA;;;;;;;AACA;;;AACA;;;AACA;AACA;;;;;;;;;;;AACA;AACA;;;AACA;;;AACA;AACA;;;;;;;;;;;;;AACA;AACA;;;AACA;AACA;AACA;;;AACA;AACA;;;AACA;AACA;;;AACA;AACA;AACA;;;AACA;AACA;;AACA;;AACA;AACA;AACA;;;AACA;;AACA;AACA;;AACA;AAIA;;;AACA;;;;;;AACA;;AACA;AACA;;;;;;AACA;;AACA;AACA;;;;;;;;AACA;;AACA;;;AACA;AACA;;;;;;;;AACA;;AACA;AACA;AAIA;;;AACA;;AACA;;;;;;;;;;;;AACA;;AACA;AACA;;AACA;;;;;;;;;;;;AACA;;AACA;AACA;;AACA;;;;;;;;;;;;;;AACA;;AACA;;;AACA;AACA;;AACA;;;;;;;;;;;;;;AACA;;AACA;AACA;AAIA;;;AACA;;AACA;AACA;AACA;;AACA;;AACA;;;AACA;AACA;AAIA;;;AACA;AAEA;AACA;AAIA;;;AACA;;AACA;;AACA;AAEA;AACA;;;;;;;AACA;;AACA;AACA;AAIA;;;AACA;AACA;;AACA;;AACA;AAEA;AACA;;AACA;;;AACA;;AACA;;AACA;AACA;AACA;;;AACA;;AACA;AACA;;AACA;AAIA;;;AACA;;AACA;;AACA;AAEA;AACA;;AAEA;AACA;AAIA;;;AACA;AACA;;AACA;;AACA;AAEA;AACA;;AAEA;AACA;;AACA;;;AACA;;AACA;;AACA;AACA;AACA;;;AACA;;AACA;AACA;;AACA;AAIA;;;AACA;;AACA;;AACA;AAEA;AACA;;AAEA;AACA;AAIA;;;AACA;AACA;;AACA;;AACA;AAEA;AACA;;AAEA;AACA;;AACA;;;AACA;;AACA;;AACA;AACA;AACA;;;AACA;;AACA;AACA;;AACA;AAIA;;;AACA;AACA;AAIA;;;AACA;AACA;;AACA;;;AACA;;AACA;;AACA;AACA;AACA;;;AACA;;AACA;AACA;;AACA;AAIA;;;AACA;AACA;;;;;;;AACA;;AACA;;;AACA;AACA;;AACA;;AACA;AACA;AACA;;;AACA;;AACA;AACA;;AACA;AAIA;;;AACA;AACA;;AACA;;AACA;AAIA;;;AACA;AACA;;;;;;;;;;;;;;;AACA;;AACA;;;AACA;AACA;;AACA;;AACA;AACA;AACA;;;AACA;;AACA;AACA;;AACA;AAIA;;;AACA;AACA;AACA;;;AACA;;AACA;;AACA;;;AACA;;AACA;AACA;;AACA;AACA;AACA;AAIA;;;AACA;AACA;AACA;AACA;;;AACA;;AACA;;AACA;AACA;AACA;;AACA;;AACA;;AACA;AACA;AACA;AACA;;AACA;;AACA;;;AACA;;AACA;AACA;;AACA;AACA;AACA;AAIA;;;AACA;AACA;;;AACA;;AACA;AACA;;AACA;AACA;AACA;AAIA;;;AACA;AACA;AACA;AACA;AACA;;;AACA;AACA;;AACA;;;AACA;AACA;;AACA;;;AACA;;AACA;;;AACA;;AACA;;AACA;;AACA;;AACA;;AACA;;;AACA;AAIA;;;AACA;AACA;AACA;AACA;AACA;;;AACA;AACA;;AACA;;;AACA;AACA;;AACA;;;AACA;;AACA;;;AACA;;AACA;;AACA;;AACA;;AACA;;AACA;;;AACA;AAIA;;;AACA;AACA;AACA;;;AACA;;AACA;;;AACA;;AACA;;AACA;;AACA;;;AACA;AAIA;;;AACA;;;AACA;AAIA;;;AACA;AACA;AACA;;;AACA;;AACA;;AACA;;;AACA;;AACA;AACA;;AACA;AACA;AACA;AAIA;;;AACA;AACA;AACA;;;AACA;;AACA;;AACA;;;AACA;;AACA;AACA;;AACA;AACA;AACA;AAIA;;;AACA;AACA;AACA;;;AACA;;AACA;;AACA;;;AACA;;AACA;AACA;;AACA;AACA;AACA;AAIA;;;AACA;;;AACA;AAIA;;;AACA;AACA;AACA;;;AACA;;AACA;;AACA;;;AACA;;AACA;AACA;;AACA;AACA;AACA;AAIA;;;AACA;AACA;AACA;;;AACA;;AACA;;AACA;;;AACA;;AACA;AACA;;AACA;AACA;AACA;AAIA;;;AACA;AACA;AACA;;;AACA;AACA;;AACA;;AACA;;;AACA;;AACA;AACA;;AACA;AACA;AACA;AACA;AAIA;;;AACA;AACA;AACA;;;AACA;;AACA;;AACA;;;AACA;;AACA;AACA;;AACA;AACA;AACA", + "pcToLocation": {}, + "sourceAndLineToPc": {} + }, + "clearSourceMap": { + "version": 3, + "sources": [ + "" + ], + "names": [], + "mappings": ";AACA;;AACA", + "pcToLocation": {}, + "sourceAndLineToPc": {} + } +} diff --git a/tests/artifacts/testing_app_arc56/arc56_app_spec.json b/tests/artifacts/testing_app_arc56/arc56_app_spec.json new file mode 100644 index 00000000..da275d16 --- /dev/null +++ b/tests/artifacts/testing_app_arc56/arc56_app_spec.json @@ -0,0 +1,681 @@ +{ + "name": "Templates", + "desc": "", + "methods": [ + { + "name": "tmpl", + "args": [], + "returns": { + "type": "void" + }, + "actions": { + "create": [], + "call": [ + "NoOp" + ] + } + }, + { + "name": "specificLengthTemplateVar", + "args": [], + "returns": { + "type": "void" + }, + "actions": { + "create": [], + "call": [ + "NoOp" + ] + } + }, + { + "name": "throwError", + "args": [], + "returns": { + "type": "void" + }, + "actions": { + "create": [], + "call": [ + "NoOp" + ] + } + }, + { + "name": "itobTemplateVar", + "args": [], + "returns": { + "type": "byte[]" + }, + "actions": { + "create": [], + "call": [ + "NoOp" + ] + } + }, + { + "name": "createApplication", + "args": [], + "returns": { + "type": "void" + }, + "actions": { + "create": [ + "NoOp" + ], + "call": [] + } + } + ], + "arcs": [ + 4, + 56 + ], + "structs": {}, + "state": { + "schema": { + "global": { + "bytes": 0, + "ints": 0 + }, + "local": { + "bytes": 0, + "ints": 0 + } + }, + "keys": { + "global": {}, + "local": {}, + "box": {} + }, + "maps": { + "global": {}, + "local": {}, + "box": {} + } + }, + "bareActions": { + "create": [], + "call": [] + }, + "sourceInfo": { + "approval": { + "sourceInfo": [ + { + "teal": 15, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:3", + "pc": [ + 1, + 2 + ] + }, + { + "teal": 16, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:3", + "pc": [ + 3 + ] + }, + { + "teal": 17, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:3", + "pc": [ + 4, + 5 + ] + }, + { + "teal": 18, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:3", + "pc": [ + 6 + ] + }, + { + "teal": 19, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:3", + "pc": [ + 7, + 8 + ] + }, + { + "teal": 20, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:3", + "pc": [ + 9 + ] + }, + { + "teal": 21, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:3", + "pc": [ + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35 + ] + }, + { + "teal": 25, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:3", + "errorMessage": "The requested action is not implemented in this contract. Are you using the correct OnComplete? Did you set your app ID?", + "pc": [ + 36 + ] + }, + { + "teal": 30, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:12", + "pc": [ + 37, + 38, + 39 + ] + }, + { + "teal": 31, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:12", + "pc": [ + 40 + ] + }, + { + "teal": 32, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:12", + "pc": [ + 41 + ] + }, + { + "teal": 36, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:12", + "pc": [ + 42, + 43, + 44 + ] + }, + { + "teal": 40, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:13", + "pc": [ + 45 + ] + }, + { + "teal": 41, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:13", + "pc": [ + 46 + ] + }, + { + "teal": 45, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:14", + "pc": [ + 47 + ] + }, + { + "teal": 46, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:14", + "pc": [ + 48 + ] + }, + { + "teal": 47, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:12", + "pc": [ + 49 + ] + }, + { + "teal": 52, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:17", + "pc": [ + 50, + 51, + 52 + ] + }, + { + "teal": 53, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:17", + "pc": [ + 53 + ] + }, + { + "teal": 54, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:17", + "pc": [ + 54 + ] + }, + { + "teal": 58, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:17", + "pc": [ + 55, + 56, + 57 + ] + }, + { + "teal": 62, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:18", + "pc": [ + 58 + ] + }, + { + "teal": 63, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:18", + "pc": [ + 59 + ] + }, + { + "teal": 64, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:18", + "pc": [ + 60 + ] + }, + { + "teal": 65, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:18", + "pc": [ + 61 + ] + }, + { + "teal": 66, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:17", + "pc": [ + 62 + ] + }, + { + "teal": 71, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:21", + "pc": [ + 63, + 64, + 65 + ] + }, + { + "teal": 72, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:21", + "pc": [ + 66 + ] + }, + { + "teal": 73, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:21", + "pc": [ + 67 + ] + }, + { + "teal": 77, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:21", + "pc": [ + 68, + 69, + 70 + ] + }, + { + "teal": 80, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:22", + "errorMessage": "this is an error", + "pc": [ + 71 + ] + }, + { + "teal": 81, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:21", + "pc": [ + 72 + ] + }, + { + "teal": 86, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:25", + "pc": [ + 73, + 74, + 75, + 76, + 77, + 78 + ] + }, + { + "teal": 89, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:25", + "pc": [ + 79, + 80, + 81 + ] + }, + { + "teal": 90, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:25", + "pc": [ + 82 + ] + }, + { + "teal": 91, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:25", + "pc": [ + 83 + ] + }, + { + "teal": 92, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:25", + "pc": [ + 84 + ] + }, + { + "teal": 93, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:25", + "pc": [ + 85, + 86, + 87 + ] + }, + { + "teal": 94, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:25", + "pc": [ + 88 + ] + }, + { + "teal": 95, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:25", + "pc": [ + 89 + ] + }, + { + "teal": 96, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:25", + "pc": [ + 90 + ] + }, + { + "teal": 97, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:25", + "pc": [ + 91 + ] + }, + { + "teal": 98, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:25", + "pc": [ + 92 + ] + }, + { + "teal": 99, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:25", + "pc": [ + 93 + ] + }, + { + "teal": 103, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:25", + "pc": [ + 94, + 95, + 96 + ] + }, + { + "teal": 107, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:26", + "pc": [ + 97 + ] + }, + { + "teal": 108, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:26", + "pc": [ + 98 + ] + }, + { + "teal": 109, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:25", + "pc": [ + 99 + ] + }, + { + "teal": 112, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:3", + "pc": [ + 100 + ] + }, + { + "teal": 113, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:3", + "pc": [ + 101 + ] + }, + { + "teal": 116, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:3", + "pc": [ + 102, + 103, + 104, + 105, + 106, + 107 + ] + }, + { + "teal": 117, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:3", + "pc": [ + 108, + 109, + 110 + ] + }, + { + "teal": 118, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:3", + "pc": [ + 111, + 112, + 113, + 114 + ] + }, + { + "teal": 121, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:3", + "errorMessage": "this contract does not implement the given ABI method for create NoOp", + "pc": [ + 115 + ] + }, + { + "teal": 124, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:3", + "pc": [ + 116, + 117, + 118, + 119, + 120, + 121 + ] + }, + { + "teal": 125, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:3", + "pc": [ + 122, + 123, + 124, + 125, + 126, + 127 + ] + }, + { + "teal": 126, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:3", + "pc": [ + 128, + 129, + 130, + 131, + 132, + 133 + ] + }, + { + "teal": 127, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:3", + "pc": [ + 134, + 135, + 136, + 137, + 138, + 139 + ] + }, + { + "teal": 128, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:3", + "pc": [ + 140, + 141, + 142 + ] + }, + { + "teal": 129, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:3", + "pc": [ + 143, + 144, + 145, + 146, + 147, + 148, + 149, + 150, + 151, + 152 + ] + }, + { + "teal": 132, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:3", + "errorMessage": "this contract does not implement the given ABI method for call NoOp", + "pc": [ + 153 + ] + } + ], + "pcOffsetMethod": "cblocks" + }, + "clear": { + "sourceInfo": [], + "pcOffsetMethod": "none" + } + }, + "source": { + "approval": "I3ByYWdtYSB2ZXJzaW9uIDEwCmludGNibG9jayAxIFRNUExfdWludDY0VG1wbFZhcgpieXRlY2Jsb2NrIFRNUExfYnl0ZXNUbXBsVmFyIFRNUExfYnl0ZXM2NFRtcGxWYXIgVE1QTF9ieXRlczMyVG1wbFZhcgoKLy8gVGhpcyBURUFMIHdhcyBnZW5lcmF0ZWQgYnkgVEVBTFNjcmlwdCB2MC4xMDUuMwovLyBodHRwczovL2dpdGh1Yi5jb20vYWxnb3JhbmRmb3VuZGF0aW9uL1RFQUxTY3JpcHQKCi8vIFRoaXMgY29udHJhY3QgaXMgY29tcGxpYW50IHdpdGggYW5kL29yIGltcGxlbWVudHMgdGhlIGZvbGxvd2luZyBBUkNzOiBbIEFSQzQgXQoKLy8gVGhlIGZvbGxvd2luZyB0ZW4gbGluZXMgb2YgVEVBTCBoYW5kbGUgaW5pdGlhbCBwcm9ncmFtIGZsb3cKLy8gVGhpcyBwYXR0ZXJuIGlzIHVzZWQgdG8gbWFrZSBpdCBlYXN5IGZvciBhbnlvbmUgdG8gcGFyc2UgdGhlIHN0YXJ0IG9mIHRoZSBwcm9ncmFtIGFuZCBkZXRlcm1pbmUgaWYgYSBzcGVjaWZpYyBhY3Rpb24gaXMgYWxsb3dlZAovLyBIZXJlLCBhY3Rpb24gcmVmZXJzIHRvIHRoZSBPbkNvbXBsZXRlIGluIGNvbWJpbmF0aW9uIHdpdGggd2hldGhlciB0aGUgYXBwIGlzIGJlaW5nIGNyZWF0ZWQgb3IgY2FsbGVkCi8vIEV2ZXJ5IHBvc3NpYmxlIGFjdGlvbiBmb3IgdGhpcyBjb250cmFjdCBpcyByZXByZXNlbnRlZCBpbiB0aGUgc3dpdGNoIHN0YXRlbWVudAovLyBJZiB0aGUgYWN0aW9uIGlzIG5vdCBpbXBsZW1lbnRlZCBpbiB0aGUgY29udHJhY3QsIGl0cyByZXNwZWN0aXZlIGJyYW5jaCB3aWxsIGJlICIqTk9UX0lNUExFTUVOVEVEIiB3aGljaCBqdXN0IGNvbnRhaW5zICJlcnIiCnR4biBBcHBsaWNhdGlvbklECiEKcHVzaGludCA2CioKdHhuIE9uQ29tcGxldGlvbgorCnN3aXRjaCAqY2FsbF9Ob09wICpOT1RfSU1QTEVNRU5URUQgKk5PVF9JTVBMRU1FTlRFRCAqTk9UX0lNUExFTUVOVEVEICpOT1RfSU1QTEVNRU5URUQgKk5PVF9JTVBMRU1FTlRFRCAqY3JlYXRlX05vT3AgKk5PVF9JTVBMRU1FTlRFRCAqTk9UX0lNUExFTUVOVEVEICpOT1RfSU1QTEVNRU5URUQgKk5PVF9JTVBMRU1FTlRFRCAqTk9UX0lNUExFTUVOVEVECgoqTk9UX0lNUExFTUVOVEVEOgoJLy8gVGhlIHJlcXVlc3RlZCBhY3Rpb24gaXMgbm90IGltcGxlbWVudGVkIGluIHRoaXMgY29udHJhY3QuIEFyZSB5b3UgdXNpbmcgdGhlIGNvcnJlY3QgT25Db21wbGV0ZT8gRGlkIHlvdSBzZXQgeW91ciBhcHAgSUQ/CgllcnIKCi8vIHRtcGwoKXZvaWQKKmFiaV9yb3V0ZV90bXBsOgoJLy8gZXhlY3V0ZSB0bXBsKCl2b2lkCgljYWxsc3ViIHRtcGwKCWludGMgMCAvLyAxCglyZXR1cm4KCi8vIHRtcGwoKTogdm9pZAp0bXBsOgoJcHJvdG8gMCAwCgoJLy8gdGVzdHMvZXhhbXBsZS1jb250cmFjdHMvYXJjNTZfdGVtcGxhdGVzL3RlbXBsYXRlcy5hbGdvLnRzOjEzCgkvLyBsb2codGhpcy5ieXRlc1RtcGxWYXIpCglieXRlYyAwIC8vIFRNUExfYnl0ZXNUbXBsVmFyCglsb2cKCgkvLyB0ZXN0cy9leGFtcGxlLWNvbnRyYWN0cy9hcmM1Nl90ZW1wbGF0ZXMvdGVtcGxhdGVzLmFsZ28udHM6MTQKCS8vIGFzc2VydCh0aGlzLnVpbnQ2NFRtcGxWYXIpCglpbnRjIDEgLy8gVE1QTF91aW50NjRUbXBsVmFyCglhc3NlcnQKCXJldHN1YgoKLy8gc3BlY2lmaWNMZW5ndGhUZW1wbGF0ZVZhcigpdm9pZAoqYWJpX3JvdXRlX3NwZWNpZmljTGVuZ3RoVGVtcGxhdGVWYXI6CgkvLyBleGVjdXRlIHNwZWNpZmljTGVuZ3RoVGVtcGxhdGVWYXIoKXZvaWQKCWNhbGxzdWIgc3BlY2lmaWNMZW5ndGhUZW1wbGF0ZVZhcgoJaW50YyAwIC8vIDEKCXJldHVybgoKLy8gc3BlY2lmaWNMZW5ndGhUZW1wbGF0ZVZhcigpOiB2b2lkCnNwZWNpZmljTGVuZ3RoVGVtcGxhdGVWYXI6Cglwcm90byAwIDAKCgkvLyB0ZXN0cy9leGFtcGxlLWNvbnRyYWN0cy9hcmM1Nl90ZW1wbGF0ZXMvdGVtcGxhdGVzLmFsZ28udHM6MTgKCS8vIGVkMjU1MTlWZXJpZnlCYXJlKHRoaXMuYnl0ZXNUbXBsVmFyLCB0aGlzLmJ5dGVzNjRUbXBsVmFyLCB0aGlzLmJ5dGVzMzJUbXBsVmFyKQoJYnl0ZWMgMCAvLyBUTVBMX2J5dGVzVG1wbFZhcgoJYnl0ZWMgMSAvLyBUTVBMX2J5dGVzNjRUbXBsVmFyCglieXRlYyAyIC8vIFRNUExfYnl0ZXMzMlRtcGxWYXIKCWVkMjU1MTl2ZXJpZnlfYmFyZQoJcmV0c3ViCgovLyB0aHJvd0Vycm9yKCl2b2lkCiphYmlfcm91dGVfdGhyb3dFcnJvcjoKCS8vIGV4ZWN1dGUgdGhyb3dFcnJvcigpdm9pZAoJY2FsbHN1YiB0aHJvd0Vycm9yCglpbnRjIDAgLy8gMQoJcmV0dXJuCgovLyB0aHJvd0Vycm9yKCk6IHZvaWQKdGhyb3dFcnJvcjoKCXByb3RvIDAgMAoKCS8vIHRoaXMgaXMgYW4gZXJyb3IKCWVycgoJcmV0c3ViCgovLyBpdG9iVGVtcGxhdGVWYXIoKWJ5dGVbXQoqYWJpX3JvdXRlX2l0b2JUZW1wbGF0ZVZhcjoKCS8vIFRoZSBBQkkgcmV0dXJuIHByZWZpeAoJcHVzaGJ5dGVzIDB4MTUxZjdjNzUKCgkvLyBleGVjdXRlIGl0b2JUZW1wbGF0ZVZhcigpYnl0ZVtdCgljYWxsc3ViIGl0b2JUZW1wbGF0ZVZhcgoJZHVwCglsZW4KCWl0b2IKCWV4dHJhY3QgNiAyCglzd2FwCgljb25jYXQKCWNvbmNhdAoJbG9nCglpbnRjIDAgLy8gMQoJcmV0dXJuCgovLyBpdG9iVGVtcGxhdGVWYXIoKTogYnl0ZXMKaXRvYlRlbXBsYXRlVmFyOgoJcHJvdG8gMCAxCgoJLy8gdGVzdHMvZXhhbXBsZS1jb250cmFjdHMvYXJjNTZfdGVtcGxhdGVzL3RlbXBsYXRlcy5hbGdvLnRzOjI2CgkvLyByZXR1cm4gaXRvYih0aGlzLnVpbnQ2NFRtcGxWYXIpCglpbnRjIDEgLy8gVE1QTF91aW50NjRUbXBsVmFyCglpdG9iCglyZXRzdWIKCiphYmlfcm91dGVfY3JlYXRlQXBwbGljYXRpb246CglpbnRjIDAgLy8gMQoJcmV0dXJuCgoqY3JlYXRlX05vT3A6CglwdXNoYnl0ZXMgMHhiODQ0N2IzNiAvLyBtZXRob2QgImNyZWF0ZUFwcGxpY2F0aW9uKCl2b2lkIgoJdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMAoJbWF0Y2ggKmFiaV9yb3V0ZV9jcmVhdGVBcHBsaWNhdGlvbgoKCS8vIHRoaXMgY29udHJhY3QgZG9lcyBub3QgaW1wbGVtZW50IHRoZSBnaXZlbiBBQkkgbWV0aG9kIGZvciBjcmVhdGUgTm9PcAoJZXJyCgoqY2FsbF9Ob09wOgoJcHVzaGJ5dGVzIDB4OWE3MWQyYjQgLy8gbWV0aG9kICJ0bXBsKCl2b2lkIgoJcHVzaGJ5dGVzIDB4ZGY0ZDVjM2IgLy8gbWV0aG9kICJzcGVjaWZpY0xlbmd0aFRlbXBsYXRlVmFyKCl2b2lkIgoJcHVzaGJ5dGVzIDB4M2Q4NzBkODcgLy8gbWV0aG9kICJ0aHJvd0Vycm9yKCl2b2lkIgoJcHVzaGJ5dGVzIDB4YmMwYjE3MDYgLy8gbWV0aG9kICJpdG9iVGVtcGxhdGVWYXIoKWJ5dGVbXSIKCXR4bmEgQXBwbGljYXRpb25BcmdzIDAKCW1hdGNoICphYmlfcm91dGVfdG1wbCAqYWJpX3JvdXRlX3NwZWNpZmljTGVuZ3RoVGVtcGxhdGVWYXIgKmFiaV9yb3V0ZV90aHJvd0Vycm9yICphYmlfcm91dGVfaXRvYlRlbXBsYXRlVmFyCgoJLy8gdGhpcyBjb250cmFjdCBkb2VzIG5vdCBpbXBsZW1lbnQgdGhlIGdpdmVuIEFCSSBtZXRob2QgZm9yIGNhbGwgTm9PcAoJZXJy", + "clear": "I3ByYWdtYSB2ZXJzaW9uIDEw" + }, + "templateVariables": { + "bytesTmplVar": { + "type": "byte[]" + }, + "uint64TmplVar": { + "type": "uint64" + }, + "bytes32TmplVar": { + "type": "byte[32]" + }, + "bytes64TmplVar": { + "type": "byte[64]" + } + }, + "scratchVariables": { + "bytesTmplVar": { + "type": "byte[]", + "slot": 200 + }, + "uint64TmplVar": { + "type": "uint64", + "slot": 201 + }, + "bytes32TmplVar": { + "type": "byte[32]", + "slot": 202 + }, + "bytes64TmplVar": { + "type": "byte[64]", + "slot": 203 + } + }, + "compilerInfo": { + "compiler": "algod", + "compilerVersion": { + "major": 3, + "minor": 26, + "patch": 0, + "commitHash": "0d10b244" + } + } +} \ No newline at end of file diff --git a/tests/artifacts/testing_app_puya/arc32_app_spec.json b/tests/artifacts/testing_app_puya/arc32_app_spec.json new file mode 100644 index 00000000..d8518906 --- /dev/null +++ b/tests/artifacts/testing_app_puya/arc32_app_spec.json @@ -0,0 +1,184 @@ +{ + "hints": { + "set_box_bytes(string,byte[])void": { + "call_config": { + "no_op": "CALL" + } + }, + "set_box_str(string,string)void": { + "call_config": { + "no_op": "CALL" + } + }, + "set_box_int(string,uint32)void": { + "call_config": { + "no_op": "CALL" + } + }, + "set_box_int512(string,uint512)void": { + "call_config": { + "no_op": "CALL" + } + }, + "set_box_static(string,byte[4])void": { + "call_config": { + "no_op": "CALL" + } + }, + "set_struct(string,(string,uint64))void": { + "call_config": { + "no_op": "CALL" + }, + "structs": { + "value": { + "name": "DummyStruct", + "elements": [ + [ + "name", + "string" + ], + [ + "id", + "uint64" + ] + ] + } + } + } + }, + "source": { + "approval": "I3ByYWdtYSB2ZXJzaW9uIDEwCgpzbWFydF9jb250cmFjdHMuaGVsbG9fd29ybGQzLmNvbnRyYWN0LlRlc3RQdXlhQm94ZXMuYXBwcm92YWxfcHJvZ3JhbToKICAgIGludGNibG9jayAxIDAKICAgIGNhbGxzdWIgX19wdXlhX2FyYzRfcm91dGVyX18KICAgIHJldHVybgoKCi8vIHNtYXJ0X2NvbnRyYWN0cy5oZWxsb193b3JsZDMuY29udHJhY3QuVGVzdFB1eWFCb3hlcy5fX3B1eWFfYXJjNF9yb3V0ZXJfXygpIC0+IHVpbnQ2NDoKX19wdXlhX2FyYzRfcm91dGVyX186CiAgICAvLyBzbWFydF9jb250cmFjdHMvaGVsbG9fd29ybGQzL2NvbnRyYWN0LnB5OjExCiAgICAvLyBjbGFzcyBUZXN0UHV5YUJveGVzKEFSQzRDb250cmFjdCk6CiAgICBwcm90byAwIDEKICAgIHR4biBOdW1BcHBBcmdzCiAgICBieiBfX3B1eWFfYXJjNF9yb3V0ZXJfX19iYXJlX3JvdXRpbmdAMTAKICAgIHB1c2hieXRlc3MgMHgyMDJmYTczYSAweGRmN2VlYTRhIDB4MzY4OGVkMmMgMHg1ZDE3MjBkZCAweGY4MDY2NjVjIDB4ODFkMjYwZTIgLy8gbWV0aG9kICJzZXRfYm94X2J5dGVzKHN0cmluZyxieXRlW10pdm9pZCIsIG1ldGhvZCAic2V0X2JveF9zdHIoc3RyaW5nLHN0cmluZyl2b2lkIiwgbWV0aG9kICJzZXRfYm94X2ludChzdHJpbmcsdWludDMyKXZvaWQiLCBtZXRob2QgInNldF9ib3hfaW50NTEyKHN0cmluZyx1aW50NTEyKXZvaWQiLCBtZXRob2QgInNldF9ib3hfc3RhdGljKHN0cmluZyxieXRlWzRdKXZvaWQiLCBtZXRob2QgInNldF9zdHJ1Y3Qoc3RyaW5nLChzdHJpbmcsdWludDY0KSl2b2lkIgogICAgdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMAogICAgbWF0Y2ggX19wdXlhX2FyYzRfcm91dGVyX19fc2V0X2JveF9ieXRlc19yb3V0ZUAyIF9fcHV5YV9hcmM0X3JvdXRlcl9fX3NldF9ib3hfc3RyX3JvdXRlQDMgX19wdXlhX2FyYzRfcm91dGVyX19fc2V0X2JveF9pbnRfcm91dGVANCBfX3B1eWFfYXJjNF9yb3V0ZXJfX19zZXRfYm94X2ludDUxMl9yb3V0ZUA1IF9fcHV5YV9hcmM0X3JvdXRlcl9fX3NldF9ib3hfc3RhdGljX3JvdXRlQDYgX19wdXlhX2FyYzRfcm91dGVyX19fc2V0X3N0cnVjdF9yb3V0ZUA3CiAgICBpbnRjXzEgLy8gMAogICAgcmV0c3ViCgpfX3B1eWFfYXJjNF9yb3V0ZXJfX19zZXRfYm94X2J5dGVzX3JvdXRlQDI6CiAgICAvLyBzbWFydF9jb250cmFjdHMvaGVsbG9fd29ybGQzL2NvbnRyYWN0LnB5OjIwCiAgICAvLyBAYXJjNC5hYmltZXRob2QKICAgIHR4biBPbkNvbXBsZXRpb24KICAgICEKICAgIGFzc2VydCAvLyBPbkNvbXBsZXRpb24gaXMgbm90IE5vT3AKICAgIHR4biBBcHBsaWNhdGlvbklECiAgICBhc3NlcnQgLy8gY2FuIG9ubHkgY2FsbCB3aGVuIG5vdCBjcmVhdGluZwogICAgLy8gc21hcnRfY29udHJhY3RzL2hlbGxvX3dvcmxkMy9jb250cmFjdC5weToxMQogICAgLy8gY2xhc3MgVGVzdFB1eWFCb3hlcyhBUkM0Q29udHJhY3QpOgogICAgdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMQogICAgdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMgogICAgZXh0cmFjdCAyIDAKICAgIC8vIHNtYXJ0X2NvbnRyYWN0cy9oZWxsb193b3JsZDMvY29udHJhY3QucHk6MjAKICAgIC8vIEBhcmM0LmFiaW1ldGhvZAogICAgY2FsbHN1YiBzZXRfYm94X2J5dGVzCiAgICBpbnRjXzAgLy8gMQogICAgcmV0c3ViCgpfX3B1eWFfYXJjNF9yb3V0ZXJfX19zZXRfYm94X3N0cl9yb3V0ZUAzOgogICAgLy8gc21hcnRfY29udHJhY3RzL2hlbGxvX3dvcmxkMy9jb250cmFjdC5weToyNAogICAgLy8gQGFyYzQuYWJpbWV0aG9kCiAgICB0eG4gT25Db21wbGV0aW9uCiAgICAhCiAgICBhc3NlcnQgLy8gT25Db21wbGV0aW9uIGlzIG5vdCBOb09wCiAgICB0eG4gQXBwbGljYXRpb25JRAogICAgYXNzZXJ0IC8vIGNhbiBvbmx5IGNhbGwgd2hlbiBub3QgY3JlYXRpbmcKICAgIC8vIHNtYXJ0X2NvbnRyYWN0cy9oZWxsb193b3JsZDMvY29udHJhY3QucHk6MTEKICAgIC8vIGNsYXNzIFRlc3RQdXlhQm94ZXMoQVJDNENvbnRyYWN0KToKICAgIHR4bmEgQXBwbGljYXRpb25BcmdzIDEKICAgIHR4bmEgQXBwbGljYXRpb25BcmdzIDIKICAgIC8vIHNtYXJ0X2NvbnRyYWN0cy9oZWxsb193b3JsZDMvY29udHJhY3QucHk6MjQKICAgIC8vIEBhcmM0LmFiaW1ldGhvZAogICAgY2FsbHN1YiBzZXRfYm94X3N0cgogICAgaW50Y18wIC8vIDEKICAgIHJldHN1YgoKX19wdXlhX2FyYzRfcm91dGVyX19fc2V0X2JveF9pbnRfcm91dGVANDoKICAgIC8vIHNtYXJ0X2NvbnRyYWN0cy9oZWxsb193b3JsZDMvY29udHJhY3QucHk6MjgKICAgIC8vIEBhcmM0LmFiaW1ldGhvZAogICAgdHhuIE9uQ29tcGxldGlvbgogICAgIQogICAgYXNzZXJ0IC8vIE9uQ29tcGxldGlvbiBpcyBub3QgTm9PcAogICAgdHhuIEFwcGxpY2F0aW9uSUQKICAgIGFzc2VydCAvLyBjYW4gb25seSBjYWxsIHdoZW4gbm90IGNyZWF0aW5nCiAgICAvLyBzbWFydF9jb250cmFjdHMvaGVsbG9fd29ybGQzL2NvbnRyYWN0LnB5OjExCiAgICAvLyBjbGFzcyBUZXN0UHV5YUJveGVzKEFSQzRDb250cmFjdCk6CiAgICB0eG5hIEFwcGxpY2F0aW9uQXJncyAxCiAgICB0eG5hIEFwcGxpY2F0aW9uQXJncyAyCiAgICAvLyBzbWFydF9jb250cmFjdHMvaGVsbG9fd29ybGQzL2NvbnRyYWN0LnB5OjI4CiAgICAvLyBAYXJjNC5hYmltZXRob2QKICAgIGNhbGxzdWIgc2V0X2JveF9pbnQKICAgIGludGNfMCAvLyAxCiAgICByZXRzdWIKCl9fcHV5YV9hcmM0X3JvdXRlcl9fX3NldF9ib3hfaW50NTEyX3JvdXRlQDU6CiAgICAvLyBzbWFydF9jb250cmFjdHMvaGVsbG9fd29ybGQzL2NvbnRyYWN0LnB5OjMyCiAgICAvLyBAYXJjNC5hYmltZXRob2QKICAgIHR4biBPbkNvbXBsZXRpb24KICAgICEKICAgIGFzc2VydCAvLyBPbkNvbXBsZXRpb24gaXMgbm90IE5vT3AKICAgIHR4biBBcHBsaWNhdGlvbklECiAgICBhc3NlcnQgLy8gY2FuIG9ubHkgY2FsbCB3aGVuIG5vdCBjcmVhdGluZwogICAgLy8gc21hcnRfY29udHJhY3RzL2hlbGxvX3dvcmxkMy9jb250cmFjdC5weToxMQogICAgLy8gY2xhc3MgVGVzdFB1eWFCb3hlcyhBUkM0Q29udHJhY3QpOgogICAgdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMQogICAgdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMgogICAgLy8gc21hcnRfY29udHJhY3RzL2hlbGxvX3dvcmxkMy9jb250cmFjdC5weTozMgogICAgLy8gQGFyYzQuYWJpbWV0aG9kCiAgICBjYWxsc3ViIHNldF9ib3hfaW50NTEyCiAgICBpbnRjXzAgLy8gMQogICAgcmV0c3ViCgpfX3B1eWFfYXJjNF9yb3V0ZXJfX19zZXRfYm94X3N0YXRpY19yb3V0ZUA2OgogICAgLy8gc21hcnRfY29udHJhY3RzL2hlbGxvX3dvcmxkMy9jb250cmFjdC5weTozNgogICAgLy8gQGFyYzQuYWJpbWV0aG9kCiAgICB0eG4gT25Db21wbGV0aW9uCiAgICAhCiAgICBhc3NlcnQgLy8gT25Db21wbGV0aW9uIGlzIG5vdCBOb09wCiAgICB0eG4gQXBwbGljYXRpb25JRAogICAgYXNzZXJ0IC8vIGNhbiBvbmx5IGNhbGwgd2hlbiBub3QgY3JlYXRpbmcKICAgIC8vIHNtYXJ0X2NvbnRyYWN0cy9oZWxsb193b3JsZDMvY29udHJhY3QucHk6MTEKICAgIC8vIGNsYXNzIFRlc3RQdXlhQm94ZXMoQVJDNENvbnRyYWN0KToKICAgIHR4bmEgQXBwbGljYXRpb25BcmdzIDEKICAgIHR4bmEgQXBwbGljYXRpb25BcmdzIDIKICAgIC8vIHNtYXJ0X2NvbnRyYWN0cy9oZWxsb193b3JsZDMvY29udHJhY3QucHk6MzYKICAgIC8vIEBhcmM0LmFiaW1ldGhvZAogICAgY2FsbHN1YiBzZXRfYm94X3N0YXRpYwogICAgaW50Y18wIC8vIDEKICAgIHJldHN1YgoKX19wdXlhX2FyYzRfcm91dGVyX19fc2V0X3N0cnVjdF9yb3V0ZUA3OgogICAgLy8gc21hcnRfY29udHJhY3RzL2hlbGxvX3dvcmxkMy9jb250cmFjdC5weTo0MgogICAgLy8gQGFyYzQuYWJpbWV0aG9kKCkKICAgIHR4biBPbkNvbXBsZXRpb24KICAgICEKICAgIGFzc2VydCAvLyBPbkNvbXBsZXRpb24gaXMgbm90IE5vT3AKICAgIHR4biBBcHBsaWNhdGlvbklECiAgICBhc3NlcnQgLy8gY2FuIG9ubHkgY2FsbCB3aGVuIG5vdCBjcmVhdGluZwogICAgLy8gc21hcnRfY29udHJhY3RzL2hlbGxvX3dvcmxkMy9jb250cmFjdC5weToxMQogICAgLy8gY2xhc3MgVGVzdFB1eWFCb3hlcyhBUkM0Q29udHJhY3QpOgogICAgdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMQogICAgdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMgogICAgLy8gc21hcnRfY29udHJhY3RzL2hlbGxvX3dvcmxkMy9jb250cmFjdC5weTo0MgogICAgLy8gQGFyYzQuYWJpbWV0aG9kKCkKICAgIGNhbGxzdWIgc2V0X3N0cnVjdAogICAgaW50Y18wIC8vIDEKICAgIHJldHN1YgoKX19wdXlhX2FyYzRfcm91dGVyX19fYmFyZV9yb3V0aW5nQDEwOgogICAgLy8gc21hcnRfY29udHJhY3RzL2hlbGxvX3dvcmxkMy9jb250cmFjdC5weToxMQogICAgLy8gY2xhc3MgVGVzdFB1eWFCb3hlcyhBUkM0Q29udHJhY3QpOgogICAgdHhuIE9uQ29tcGxldGlvbgogICAgYm56IF9fcHV5YV9hcmM0X3JvdXRlcl9fX2FmdGVyX2lmX2Vsc2VAMTQKICAgIHR4biBBcHBsaWNhdGlvbklECiAgICAhCiAgICBhc3NlcnQgLy8gY2FuIG9ubHkgY2FsbCB3aGVuIGNyZWF0aW5nCiAgICBpbnRjXzAgLy8gMQogICAgcmV0c3ViCgpfX3B1eWFfYXJjNF9yb3V0ZXJfX19hZnRlcl9pZl9lbHNlQDE0OgogICAgLy8gc21hcnRfY29udHJhY3RzL2hlbGxvX3dvcmxkMy9jb250cmFjdC5weToxMQogICAgLy8gY2xhc3MgVGVzdFB1eWFCb3hlcyhBUkM0Q29udHJhY3QpOgogICAgaW50Y18xIC8vIDAKICAgIHJldHN1YgoKCi8vIHNtYXJ0X2NvbnRyYWN0cy5oZWxsb193b3JsZDMuY29udHJhY3QuVGVzdFB1eWFCb3hlcy5zZXRfYm94X2J5dGVzKG5hbWU6IGJ5dGVzLCB2YWx1ZTogYnl0ZXMpIC0+IHZvaWQ6CnNldF9ib3hfYnl0ZXM6CiAgICAvLyBzbWFydF9jb250cmFjdHMvaGVsbG9fd29ybGQzL2NvbnRyYWN0LnB5OjIwLTIxCiAgICAvLyBAYXJjNC5hYmltZXRob2QKICAgIC8vIGRlZiBzZXRfYm94X2J5dGVzKHNlbGYsIG5hbWU6IGFyYzQuU3RyaW5nLCB2YWx1ZTogQnl0ZXMpIC0+IE5vbmU6CiAgICBwcm90byAyIDAKICAgIC8vIHNtYXJ0X2NvbnRyYWN0cy9oZWxsb193b3JsZDMvY29udHJhY3QucHk6MjIKICAgIC8vIHNlbGYuYm94X2J5dGVzW25hbWVdID0gdmFsdWUKICAgIHB1c2hieXRlcyAiYm94X2J5dGVzIgogICAgZnJhbWVfZGlnIC0yCiAgICBjb25jYXQKICAgIGR1cAogICAgYm94X2RlbAogICAgcG9wCiAgICBmcmFtZV9kaWcgLTEKICAgIGJveF9wdXQKICAgIHJldHN1YgoKCi8vIHNtYXJ0X2NvbnRyYWN0cy5oZWxsb193b3JsZDMuY29udHJhY3QuVGVzdFB1eWFCb3hlcy5zZXRfYm94X3N0cihuYW1lOiBieXRlcywgdmFsdWU6IGJ5dGVzKSAtPiB2b2lkOgpzZXRfYm94X3N0cjoKICAgIC8vIHNtYXJ0X2NvbnRyYWN0cy9oZWxsb193b3JsZDMvY29udHJhY3QucHk6MjQtMjUKICAgIC8vIEBhcmM0LmFiaW1ldGhvZAogICAgLy8gZGVmIHNldF9ib3hfc3RyKHNlbGYsIG5hbWU6IGFyYzQuU3RyaW5nLCB2YWx1ZTogYXJjNC5TdHJpbmcpIC0+IE5vbmU6CiAgICBwcm90byAyIDAKICAgIC8vIHNtYXJ0X2NvbnRyYWN0cy9oZWxsb193b3JsZDMvY29udHJhY3QucHk6MjYKICAgIC8vIHNlbGYuYm94X3N0cltuYW1lXSA9IHZhbHVlCiAgICBwdXNoYnl0ZXMgImJveF9zdHIiCiAgICBmcmFtZV9kaWcgLTIKICAgIGNvbmNhdAogICAgZHVwCiAgICBib3hfZGVsCiAgICBwb3AKICAgIGZyYW1lX2RpZyAtMQogICAgYm94X3B1dAogICAgcmV0c3ViCgoKLy8gc21hcnRfY29udHJhY3RzLmhlbGxvX3dvcmxkMy5jb250cmFjdC5UZXN0UHV5YUJveGVzLnNldF9ib3hfaW50KG5hbWU6IGJ5dGVzLCB2YWx1ZTogYnl0ZXMpIC0+IHZvaWQ6CnNldF9ib3hfaW50OgogICAgLy8gc21hcnRfY29udHJhY3RzL2hlbGxvX3dvcmxkMy9jb250cmFjdC5weToyOC0yOQogICAgLy8gQGFyYzQuYWJpbWV0aG9kCiAgICAvLyBkZWYgc2V0X2JveF9pbnQoc2VsZiwgbmFtZTogYXJjNC5TdHJpbmcsIHZhbHVlOiBhcmM0LlVJbnQzMikgLT4gTm9uZToKICAgIHByb3RvIDIgMAogICAgLy8gc21hcnRfY29udHJhY3RzL2hlbGxvX3dvcmxkMy9jb250cmFjdC5weTozMAogICAgLy8gc2VsZi5ib3hfaW50W25hbWVdID0gdmFsdWUKICAgIHB1c2hieXRlcyAiYm94X2ludCIKICAgIGZyYW1lX2RpZyAtMgogICAgY29uY2F0CiAgICBmcmFtZV9kaWcgLTEKICAgIGJveF9wdXQKICAgIHJldHN1YgoKCi8vIHNtYXJ0X2NvbnRyYWN0cy5oZWxsb193b3JsZDMuY29udHJhY3QuVGVzdFB1eWFCb3hlcy5zZXRfYm94X2ludDUxMihuYW1lOiBieXRlcywgdmFsdWU6IGJ5dGVzKSAtPiB2b2lkOgpzZXRfYm94X2ludDUxMjoKICAgIC8vIHNtYXJ0X2NvbnRyYWN0cy9oZWxsb193b3JsZDMvY29udHJhY3QucHk6MzItMzMKICAgIC8vIEBhcmM0LmFiaW1ldGhvZAogICAgLy8gZGVmIHNldF9ib3hfaW50NTEyKHNlbGYsIG5hbWU6IGFyYzQuU3RyaW5nLCB2YWx1ZTogYXJjNC5VSW50NTEyKSAtPiBOb25lOgogICAgcHJvdG8gMiAwCiAgICAvLyBzbWFydF9jb250cmFjdHMvaGVsbG9fd29ybGQzL2NvbnRyYWN0LnB5OjM0CiAgICAvLyBzZWxmLmJveF9pbnQ1MTJbbmFtZV0gPSB2YWx1ZQogICAgcHVzaGJ5dGVzICJib3hfaW50NTEyIgogICAgZnJhbWVfZGlnIC0yCiAgICBjb25jYXQKICAgIGZyYW1lX2RpZyAtMQogICAgYm94X3B1dAogICAgcmV0c3ViCgoKLy8gc21hcnRfY29udHJhY3RzLmhlbGxvX3dvcmxkMy5jb250cmFjdC5UZXN0UHV5YUJveGVzLnNldF9ib3hfc3RhdGljKG5hbWU6IGJ5dGVzLCB2YWx1ZTogYnl0ZXMpIC0+IHZvaWQ6CnNldF9ib3hfc3RhdGljOgogICAgLy8gc21hcnRfY29udHJhY3RzL2hlbGxvX3dvcmxkMy9jb250cmFjdC5weTozNi0zOQogICAgLy8gQGFyYzQuYWJpbWV0aG9kCiAgICAvLyBkZWYgc2V0X2JveF9zdGF0aWMoCiAgICAvLyAgICAgc2VsZiwgbmFtZTogYXJjNC5TdHJpbmcsIHZhbHVlOiBhcmM0LlN0YXRpY0FycmF5W2FyYzQuQnl0ZSwgTGl0ZXJhbFs0XV0KICAgIC8vICkgLT4gTm9uZToKICAgIHByb3RvIDIgMAogICAgLy8gc21hcnRfY29udHJhY3RzL2hlbGxvX3dvcmxkMy9jb250cmFjdC5weTo0MAogICAgLy8gc2VsZi5ib3hfc3RhdGljW25hbWVdID0gdmFsdWUuY29weSgpCiAgICBwdXNoYnl0ZXMgImJveF9zdGF0aWMiCiAgICBmcmFtZV9kaWcgLTIKICAgIGNvbmNhdAogICAgZnJhbWVfZGlnIC0xCiAgICBib3hfcHV0CiAgICByZXRzdWIKCgovLyBzbWFydF9jb250cmFjdHMuaGVsbG9fd29ybGQzLmNvbnRyYWN0LlRlc3RQdXlhQm94ZXMuc2V0X3N0cnVjdChuYW1lOiBieXRlcywgdmFsdWU6IGJ5dGVzKSAtPiB2b2lkOgpzZXRfc3RydWN0OgogICAgLy8gc21hcnRfY29udHJhY3RzL2hlbGxvX3dvcmxkMy9jb250cmFjdC5weTo0Mi00MwogICAgLy8gQGFyYzQuYWJpbWV0aG9kKCkKICAgIC8vIGRlZiBzZXRfc3RydWN0KHNlbGYsIG5hbWU6IGFyYzQuU3RyaW5nLCB2YWx1ZTogRHVtbXlTdHJ1Y3QpIC0+IE5vbmU6CiAgICBwcm90byAyIDAKICAgIC8vIHNtYXJ0X2NvbnRyYWN0cy9oZWxsb193b3JsZDMvY29udHJhY3QucHk6NDQKICAgIC8vIGFzc2VydCBuYW1lLmJ5dGVzID09IHZhbHVlLm5hbWUuYnl0ZXMsICJOYW1lIG11c3QgbWF0Y2ggaWQgb2Ygc3RydWN0IgogICAgZnJhbWVfZGlnIC0xCiAgICBpbnRjXzEgLy8gMAogICAgZXh0cmFjdF91aW50MTYKICAgIGZyYW1lX2RpZyAtMQogICAgbGVuCiAgICBmcmFtZV9kaWcgLTEKICAgIGNvdmVyIDIKICAgIHN1YnN0cmluZzMKICAgIGZyYW1lX2RpZyAtMgogICAgPT0KICAgIGFzc2VydCAvLyBOYW1lIG11c3QgbWF0Y2ggaWQgb2Ygc3RydWN0CiAgICAvLyBzbWFydF9jb250cmFjdHMvaGVsbG9fd29ybGQzL2NvbnRyYWN0LnB5OjQ1CiAgICAvLyBvcC5Cb3gucHV0KG5hbWUuYnl0ZXMsIHZhbHVlLmJ5dGVzKQogICAgZnJhbWVfZGlnIC0yCiAgICBmcmFtZV9kaWcgLTEKICAgIGJveF9wdXQKICAgIHJldHN1Ygo=", + "clear": "I3ByYWdtYSB2ZXJzaW9uIDEwCgpzbWFydF9jb250cmFjdHMuaGVsbG9fd29ybGQzLmNvbnRyYWN0LlRlc3RQdXlhQm94ZXMuY2xlYXJfc3RhdGVfcHJvZ3JhbToKICAgIHB1c2hpbnQgMSAvLyAxCiAgICByZXR1cm4K" + }, + "state": { + "global": { + "num_byte_slices": 0, + "num_uints": 0 + }, + "local": { + "num_byte_slices": 0, + "num_uints": 0 + } + }, + "schema": { + "global": { + "declared": {}, + "reserved": {} + }, + "local": { + "declared": {}, + "reserved": {} + } + }, + "contract": { + "name": "TestPuyaBoxes", + "methods": [ + { + "name": "set_box_bytes", + "args": [ + { + "type": "string", + "name": "name" + }, + { + "type": "byte[]", + "name": "value" + } + ], + "readonly": false, + "returns": { + "type": "void" + } + }, + { + "name": "set_box_str", + "args": [ + { + "type": "string", + "name": "name" + }, + { + "type": "string", + "name": "value" + } + ], + "readonly": false, + "returns": { + "type": "void" + } + }, + { + "name": "set_box_int", + "args": [ + { + "type": "string", + "name": "name" + }, + { + "type": "uint32", + "name": "value" + } + ], + "readonly": false, + "returns": { + "type": "void" + } + }, + { + "name": "set_box_int512", + "args": [ + { + "type": "string", + "name": "name" + }, + { + "type": "uint512", + "name": "value" + } + ], + "readonly": false, + "returns": { + "type": "void" + } + }, + { + "name": "set_box_static", + "args": [ + { + "type": "string", + "name": "name" + }, + { + "type": "byte[4]", + "name": "value" + } + ], + "readonly": false, + "returns": { + "type": "void" + } + }, + { + "name": "set_struct", + "args": [ + { + "type": "string", + "name": "name" + }, + { + "type": "(string,uint64)", + "name": "value" + } + ], + "readonly": false, + "returns": { + "type": "void" + } + } + ], + "networks": {} + }, + "bare_call_config": { + "no_op": "CREATE" + } +} diff --git a/tests/artifacts/testing_app_puya/contract.py b/tests/artifacts/testing_app_puya/contract.py new file mode 100644 index 00000000..7074dd6b --- /dev/null +++ b/tests/artifacts/testing_app_puya/contract.py @@ -0,0 +1,43 @@ +from typing import Literal + +from algopy import ARC4Contract, BoxMap, Bytes, arc4, op + + +class DummyStruct(arc4.Struct): + name: arc4.String + id: arc4.UInt64 + + +class TestPuyaBoxes(ARC4Contract): + def __init__(self) -> None: + self.box_bytes = BoxMap(arc4.String, Bytes) + self.box_bytes2 = BoxMap(Bytes, Bytes) + self.box_str = BoxMap(arc4.String, arc4.String) + self.box_int = BoxMap(arc4.String, arc4.UInt32) + self.box_int512 = BoxMap(arc4.String, arc4.UInt512) + self.box_static = BoxMap(arc4.String, arc4.StaticArray[arc4.Byte, Literal[4]]) + + @arc4.abimethod + def set_box_bytes(self, name: arc4.String, value: Bytes) -> None: + self.box_bytes[name] = value + + @arc4.abimethod + def set_box_str(self, name: arc4.String, value: arc4.String) -> None: + self.box_str[name] = value + + @arc4.abimethod + def set_box_int(self, name: arc4.String, value: arc4.UInt32) -> None: + self.box_int[name] = value + + @arc4.abimethod + def set_box_int512(self, name: arc4.String, value: arc4.UInt512) -> None: + self.box_int512[name] = value + + @arc4.abimethod + def set_box_static(self, name: arc4.String, value: arc4.StaticArray[arc4.Byte, Literal[4]]) -> None: + self.box_static[name] = value.copy() + + @arc4.abimethod() + def set_struct(self, name: arc4.String, value: DummyStruct) -> None: + assert name.bytes == value.name.bytes, "Name must match id of struct" + op.Box.put(name.bytes, value.bytes) diff --git a/tests/assets/test_asset_manager.py b/tests/assets/test_asset_manager.py index 61e5c255..2d5ea4e4 100644 --- a/tests/assets/test_asset_manager.py +++ b/tests/assets/test_asset_manager.py @@ -1,6 +1,7 @@ -import algosdk import pytest -from algokit_utils import Account, get_account +from algosdk.atomic_transaction_composer import AccountTransactionSigner + +from algokit_utils import Account from algokit_utils.assets.asset_manager import ( AccountAssetInformation, AssetInformation, @@ -12,26 +13,32 @@ AssetCreateParams, PaymentParams, ) -from algosdk.atomic_transaction_composer import AccountTransactionSigner -from tests.conftest import get_unique_name +@pytest.fixture +def algorand() -> AlgorandClient: + return AlgorandClient.default_local_net() -@pytest.fixture() -def sender(funded_account: Account) -> Account: - return funded_account - -@pytest.fixture() -def receiver(algod_client: algosdk.v2client.algod.AlgodClient) -> Account: - return get_account(algod_client, get_unique_name()) +@pytest.fixture +def sender(algorand: AlgorandClient) -> Account: + new_account = algorand.account.random() + dispenser = algorand.account.localnet_dispenser() + algorand.account.ensure_funded( + new_account, dispenser, AlgoAmount.from_algos(100), min_funding_increment=AlgoAmount.from_algos(1) + ) + algorand.set_signer(sender=new_account.address, signer=new_account.signer) + return new_account -@pytest.fixture() -def algorand(funded_account: Account) -> AlgorandClient: - client = AlgorandClient.default_local_net() - client.set_signer(sender=funded_account.address, signer=funded_account.signer) - return client +@pytest.fixture +def receiver(algorand: AlgorandClient) -> Account: + new_account = algorand.account.random() + dispenser = algorand.account.localnet_dispenser() + algorand.account.ensure_funded( + new_account, dispenser, AlgoAmount.from_algos(100), min_funding_increment=AlgoAmount.from_algos(1) + ) + return new_account def test_get_by_id(algorand: AlgorandClient, sender: Account) -> None: diff --git a/tests/clients/algorand_client/__init__.py b/tests/clients/algorand_client/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/clients/algorand_client/test_transfer.py b/tests/clients/algorand_client/test_transfer.py new file mode 100644 index 00000000..a7637f58 --- /dev/null +++ b/tests/clients/algorand_client/test_transfer.py @@ -0,0 +1,427 @@ +import httpx +import pytest +from pytest_httpx._httpx_mock import HTTPXMock + +from algokit_utils.clients.algorand_client import AlgorandClient +from algokit_utils.clients.dispenser_api_client import DispenserApiConfig, TestNetDispenserApiClient +from algokit_utils.models.account import Account +from algokit_utils.models.amount import AlgoAmount +from algokit_utils.transactions.transaction_composer import ( + AssetOptInParams, + AssetTransferParams, + PaymentParams, +) +from tests.conftest import generate_test_asset + + +@pytest.fixture +def algorand() -> AlgorandClient: + return AlgorandClient.default_local_net() + + +@pytest.fixture +def funded_account(algorand: AlgorandClient) -> Account: + new_account = algorand.account.random() + dispenser = algorand.account.localnet_dispenser() + algorand.account.ensure_funded( + new_account, dispenser, AlgoAmount.from_algos(100), min_funding_increment=AlgoAmount.from_algos(1) + ) + algorand.set_signer(sender=new_account.address, signer=new_account.signer) + return new_account + + +def test_transfer_algo_is_sent_and_waited_for(algorand: AlgorandClient, funded_account: Account) -> None: + second_account = algorand.account.random() + + result = algorand.send.payment( + PaymentParams( + sender=funded_account.address, + receiver=second_account.address, + amount=AlgoAmount.from_algos(5), + note=b"Transfer 5 Algos", + ) + ) + + account_info = algorand.account.get_information(second_account) + + assert result.transaction.payment + assert result.transaction.payment.amt == 5_000_000 + + assert result.transaction.payment.sender == funded_account.address == result.confirmation["txn"]["txn"]["snd"] # type: ignore # noqa: PGH003 + assert account_info["amount"] == 5_000_000 + + +def test_transfer_algo_respects_string_lease(algorand: AlgorandClient, funded_account: Account) -> None: + second_account = algorand.account.random() + + algorand.send.payment( + PaymentParams( + sender=funded_account.address, + receiver=second_account.address, + amount=AlgoAmount.from_algos(1), + lease=b"test", + ) + ) + + with pytest.raises(Exception, match="overlapping lease"): + algorand.send.payment( + PaymentParams( + sender=funded_account.address, + receiver=second_account.address, + amount=AlgoAmount.from_algos(2), + lease=b"test", + ) + ) + + +def test_transfer_algo_respects_byte_array_lease(algorand: AlgorandClient, funded_account: Account) -> None: + second_account = algorand.account.random() + + algorand.send.payment( + PaymentParams( + sender=funded_account.address, + receiver=second_account.address, + amount=AlgoAmount.from_algos(1), + lease=b"\x01\x02\x03\x04", + ) + ) + + with pytest.raises(Exception, match="overlapping lease"): + algorand.send.payment( + PaymentParams( + sender=funded_account.address, + receiver=second_account.address, + amount=AlgoAmount.from_algos(2), + lease=b"\x01\x02\x03\x04", + ) + ) + + +def test_transfer_asa_respects_lease(algorand: AlgorandClient, funded_account: Account) -> None: + test_asset_id = generate_test_asset(algorand, funded_account, 100) + + second_account = algorand.account.random() + algorand.account.ensure_funded( + account_to_fund=second_account, + dispenser_account=funded_account, + min_spending_balance=AlgoAmount.from_algos(1), + min_funding_increment=AlgoAmount.from_algos(1), + ) + + algorand.send.asset_opt_in( + AssetOptInParams( + sender=second_account.address, + asset_id=test_asset_id, + ) + ) + + algorand.send.asset_transfer( + AssetTransferParams( + sender=funded_account.address, + receiver=second_account.address, + asset_id=test_asset_id, + amount=1, + lease=b"test", + ) + ) + + with pytest.raises(Exception, match="overlapping lease"): + algorand.send.asset_transfer( + AssetTransferParams( + sender=funded_account.address, + receiver=second_account.address, + asset_id=test_asset_id, + amount=2, + lease=b"test", + ) + ) + + +def test_transfer_asa_receiver_not_opted_in( + algorand: AlgorandClient, + funded_account: Account, +) -> None: + test_asset_id = generate_test_asset(algorand, funded_account, 100) + second_account = algorand.account.random() + + with pytest.raises(Exception, match="receiver error: must optin"): + algorand.send.asset_transfer( + AssetTransferParams( + sender=funded_account.address, + receiver=second_account.address, + asset_id=test_asset_id, + amount=1, + note=b"Transfer 5 assets with id %d" % test_asset_id, + ) + ) + + +def test_transfer_asa_sender_not_opted_in(algorand: AlgorandClient, funded_account: Account) -> None: + test_asset_id = generate_test_asset(algorand, funded_account, 100) + second_account = algorand.account.random() + algorand.account.ensure_funded( + account_to_fund=second_account, + dispenser_account=funded_account, + min_spending_balance=AlgoAmount.from_algos(1), + min_funding_increment=AlgoAmount.from_algos(1), + ) + + with pytest.raises(Exception, match=f"asset {test_asset_id} missing from {second_account.address}"): + algorand.send.asset_transfer( + AssetTransferParams( + sender=second_account.address, + receiver=funded_account.address, + asset_id=test_asset_id, + amount=1, + note=b"Transfer 5 assets with id %d" % test_asset_id, + ) + ) + + +def test_transfer_asa_asset_doesnt_exist(algorand: AlgorandClient, funded_account: Account) -> None: + second_account = algorand.account.random() + algorand.account.ensure_funded( + account_to_fund=second_account, + dispenser_account=funded_account, + min_spending_balance=AlgoAmount.from_algos(1), + min_funding_increment=AlgoAmount.from_algos(1), + ) + + with pytest.raises(Exception, match=f"asset 123123 missing from {funded_account.address}"): + algorand.send.asset_transfer( + AssetTransferParams( + sender=funded_account.address, + receiver=second_account.address, + asset_id=123123, + amount=5, + note=b"Transfer asset with wrong id", + ) + ) + + +def test_transfer_asa_to_another_account(algorand: AlgorandClient, funded_account: Account) -> None: + test_asset_id = generate_test_asset(algorand, funded_account, 100) + second_account = algorand.account.random() + algorand.account.ensure_funded( + account_to_fund=second_account, + dispenser_account=funded_account, + min_spending_balance=AlgoAmount.from_algos(1), + min_funding_increment=AlgoAmount.from_algos(1), + ) + + algorand.send.asset_opt_in( + AssetOptInParams( + sender=second_account.address, + asset_id=test_asset_id, + ) + ) + + algorand.send.asset_transfer( + AssetTransferParams( + sender=funded_account.address, + receiver=second_account.address, + asset_id=test_asset_id, + amount=5, + note=b"Transfer 5 assets with id %d" % test_asset_id, + ) + ) + + second_account_info = algorand.asset.get_account_information(second_account, test_asset_id) + assert second_account_info.balance == 5 + + test_account_info = algorand.asset.get_account_information(funded_account, test_asset_id) + assert test_account_info.balance == 95 + + +def test_transfer_asa_from_revocation_target(algorand: AlgorandClient, funded_account: Account) -> None: + test_asset_id = generate_test_asset(algorand, funded_account, 100) + second_account = algorand.account.random() + clawback_account = algorand.account.random() + + algorand.account.ensure_funded( + account_to_fund=second_account, + dispenser_account=funded_account, + min_spending_balance=AlgoAmount.from_algos(1), + min_funding_increment=AlgoAmount.from_algos(1), + ) + algorand.account.ensure_funded( + account_to_fund=clawback_account, + dispenser_account=funded_account, + min_spending_balance=AlgoAmount.from_algos(1), + min_funding_increment=AlgoAmount.from_algos(1), + ) + + algorand.send.asset_opt_in( + AssetOptInParams( + sender=second_account.address, + asset_id=test_asset_id, + ) + ) + + algorand.send.asset_opt_in( + AssetOptInParams( + sender=clawback_account.address, + asset_id=test_asset_id, + ) + ) + + algorand.send.asset_transfer( + AssetTransferParams( + sender=funded_account.address, + receiver=clawback_account.address, + asset_id=test_asset_id, + amount=5, + note=b"Transfer 5 assets with id %d" % test_asset_id, + ) + ) + + clawback_from_info = algorand.asset.get_account_information(clawback_account, test_asset_id) + assert clawback_from_info.balance == 5 + + algorand.send.asset_transfer( + AssetTransferParams( + sender=funded_account.address, + receiver=second_account.address, + asset_id=test_asset_id, + amount=5, + note=b"Transfer 5 assets with id %d" % test_asset_id, + clawback_target=clawback_account.address, + ) + ) + + second_account_info = algorand.asset.get_account_information(second_account, test_asset_id) + assert second_account_info.balance == 5 + + clawback_account_info = algorand.asset.get_account_information(clawback_account, test_asset_id) + assert clawback_account_info.balance == 0 + + test_account_info = algorand.asset.get_account_information(funded_account, test_asset_id) + assert test_account_info.balance == 95 + + +MINIMUM_BALANCE = AlgoAmount.from_micro_algos( + 100_000 +) # see https://developer.algorand.org/docs/get-details/accounts/#minimum-balance + + +def test_ensure_funded(algorand: AlgorandClient, funded_account: Account) -> None: + test_account = algorand.account.random() + response = algorand.account.ensure_funded( + account_to_fund=test_account, + dispenser_account=funded_account, + min_spending_balance=AlgoAmount.from_algos(1), + ) + assert response is not None + + to_account_info = algorand.account.get_information(test_account) + assert isinstance(to_account_info, dict) + actual_amount = to_account_info.get("amount") + assert actual_amount == MINIMUM_BALANCE + AlgoAmount.from_algos(1) + + +def test_ensure_funded_uses_dispenser_by_default( + algorand: AlgorandClient, +) -> None: + second_account = algorand.account.random() + dispenser = algorand.account.dispenser_from_environment() + + result = algorand.account.ensure_funded_from_environment( + account_to_fund=second_account, + min_spending_balance=AlgoAmount.from_algos(1), + min_funding_increment=AlgoAmount.from_algos(1), + ) + + assert result is not None + assert result.transaction.payment is not None + assert result.transaction.payment.sender == dispenser.address + + account_info = algorand.account.get_information(second_account) + assert account_info["amount"] == MINIMUM_BALANCE + AlgoAmount.from_algos(1) + + +def test_ensure_funded_respects_minimum_funding_increment(algorand: AlgorandClient, funded_account: Account) -> None: + test_account = algorand.account.random() + response = algorand.account.ensure_funded( + account_to_fund=test_account, + dispenser_account=funded_account, + min_spending_balance=AlgoAmount.from_micro_algo(1), + min_funding_increment=AlgoAmount.from_algos(1), + ) + assert response is not None + + to_account_info = algorand.account.get_information(test_account) + assert isinstance(to_account_info, dict) + actual_amount = to_account_info.get("amount") + assert actual_amount == AlgoAmount.from_algos(1) + + +def test_ensure_funded_testnet_api_success(monkeypatch: pytest.MonkeyPatch, httpx_mock: HTTPXMock) -> None: + algorand = AlgorandClient.test_net() + account_to_fund = algorand.account.random() + monkeypatch.setenv( + "ALGOKIT_DISPENSER_ACCESS_TOKEN", + "dummy", + ) + httpx_mock.add_response( + url=f"{DispenserApiConfig.BASE_URL}/fund/0", + method="POST", + json={"amount": 1, "txID": "dummy_tx_id"}, + ) + + result = algorand.account.ensure_funded_from_testnet_dispenser_api( + account_to_fund=account_to_fund, + dispenser_client=TestNetDispenserApiClient(), + min_spending_balance=AlgoAmount.from_micro_algo(1), + ) + assert result is not None + assert result.transaction_id == "dummy_tx_id" + assert result.amount_funded == AlgoAmount.from_micro_algo(1) + + +def test_ensure_funded_testnet_api_bad_response(monkeypatch: pytest.MonkeyPatch, httpx_mock: HTTPXMock) -> None: + algorand = AlgorandClient.test_net() + account_to_fund = algorand.account.random() + monkeypatch.setenv( + "ALGOKIT_DISPENSER_ACCESS_TOKEN", + "dummy", + ) + httpx_mock.add_exception( + httpx.HTTPStatusError( + "Limit exceeded", + request=httpx.Request("POST", f"{DispenserApiConfig.BASE_URL}/fund"), + response=httpx.Response( + 400, + request=httpx.Request("POST", f"{DispenserApiConfig.BASE_URL}/fund"), + json={ + "code": "fund_limit_exceeded", + "limit": 10_000_000, + "resetsAt": "2023-09-19T10:07:34.024Z", + }, + ), + ), + url=f"{DispenserApiConfig.BASE_URL}/fund/0", + method="POST", + ) + + with pytest.raises(Exception, match="fund_limit_exceeded"): + algorand.account.ensure_funded_from_testnet_dispenser_api( + account_to_fund=account_to_fund, + dispenser_client=TestNetDispenserApiClient(), + min_spending_balance=AlgoAmount.from_micro_algo(1), + ) + + +def test_rekey_works(algorand: AlgorandClient, funded_account: Account) -> None: + second_account = algorand.account.random() + + algorand.account.rekey_account(funded_account, second_account, note=b"rekey") + + # This will throw if the rekey wasn't successful + algorand.send.payment( + PaymentParams( + sender=funded_account.address, + receiver=funded_account.address, + amount=AlgoAmount.from_micro_algos(1), + signer=second_account.signer, + ) + ) diff --git a/tests/clients/test_algorand_client.py b/tests/clients/test_algorand_client.py deleted file mode 100644 index ce0f90d5..00000000 --- a/tests/clients/test_algorand_client.py +++ /dev/null @@ -1,223 +0,0 @@ -# TODO: Update tests for latest version of algokit-utils -# import json -# from pathlib import Path - -# import pytest -# from algokit_utils import Account, ApplicationClient -# from algokit_utils.accounts.account_manager import AddressAndSigner -# from algokit_utils.clients.algorand_client import ( -# AlgorandClient, -# AppMethodCallParams, -# AssetCreateParams, -# AssetOptInParams, -# PaymentParams, -# ) -# from algosdk.abi import Contract -# from algosdk.atomic_transaction_composer import AtomicTransactionComposer - - -# @pytest.fixture() -# def algorand(funded_account: Account) -> AlgorandClient: -# client = AlgorandClient.default_local_net() -# client.set_signer(sender=funded_account.address, signer=funded_account.signer) -# return client - - -# @pytest.fixture() -# def alice(algorand: AlgorandClient, funded_account: Account) -> AddressAndSigner: -# acct = algorand.account.random() -# algorand.send.payment(PaymentParams(sender=funded_account.address, receiver=acct.address, amount=1_000_000)) -# return acct - - -# @pytest.fixture() -# def bob(algorand: AlgorandClient, funded_account: Account) -> AddressAndSigner: -# acct = algorand.account.random() -# algorand.send.payment(PaymentParams(sender=funded_account.address, receiver=acct.address, amount=1_000_000)) -# return acct - - -# @pytest.fixture() -# def app_client(algorand: AlgorandClient, alice: AddressAndSigner) -> ApplicationClient: -# client = ApplicationClient( -# algorand.client.algod, -# Path(__file__).parent / "app_algorand_client.json", -# sender=alice.address, -# signer=alice.signer, -# ) -# client.create(call_abi_method="createApplication") -# return client - - -# @pytest.fixture() -# def contract() -> Contract: -# with Path.open(Path(__file__).parent / "app_algorand_client.json") as f: -# return Contract.from_json(json.dumps(json.load(f)["contract"])) - - -# def test_send_payment(algorand: AlgorandClient, alice: AddressAndSigner, bob: AddressAndSigner) -> None: -# amount = 100_000 - -# alice_pre_balance = algorand.account.get_information(alice.address)["amount"] -# bob_pre_balance = algorand.account.get_information(bob.address)["amount"] -# result = algorand.send.payment(PaymentParams(sender=alice.address, receiver=bob.address, amount=amount)) -# alice_post_balance = algorand.account.get_information(alice.address)["amount"] -# bob_post_balance = algorand.account.get_information(bob.address)["amount"] - -# assert result["confirmation"] is not None -# assert alice_post_balance == alice_pre_balance - 1000 - amount -# assert bob_post_balance == bob_pre_balance + amount - - -# def test_send_asset_create(algorand: AlgorandClient, alice: AddressAndSigner) -> None: -# total = 100 - -# result = algorand.send.asset_create(AssetCreateParams(sender=alice.address, total=total)) -# asset_index = result["confirmation"]["asset-index"] - -# assert asset_index > 0 - - -# def test_asset_opt_in(algorand: AlgorandClient, alice: AddressAndSigner, bob: AddressAndSigner) -> None: -# total = 100 - -# result = algorand.send.asset_create(AssetCreateParams(sender=alice.address, total=total)) -# asset_index = result["confirmation"]["asset-index"] - -# algorand.send.asset_opt_in(AssetOptInParams(sender=bob.address, asset_id=asset_index)) - -# assert algorand.account.get_asset_information(bob.address, asset_index) is not None - - -# DO_MATH_VALUE = 3 - - -# def test_add_atc(algorand: AlgorandClient, app_client: ApplicationClient, alice: AddressAndSigner) -> None: -# atc = AtomicTransactionComposer() -# app_client.compose_call(atc, call_abi_method="doMath", a=1, b=2, operation="sum") - -# result = ( -# algorand.new_group() -# .add_payment(PaymentParams(sender=alice.address, amount=0, receiver=alice.address)) -# .add_atc(atc) -# .execute() -# ) -# assert result.abi_results[0].return_value == DO_MATH_VALUE - - -# def test_add_method_call( -# algorand: AlgorandClient, contract: Contract, alice: AddressAndSigner, app_client: ApplicationClient -# ) -> None: -# result = ( -# algorand.new_group() -# .add_payment(PaymentParams(sender=alice.address, amount=0, receiver=alice.address)) -# .add_method_call( -# AppMethodCallParams( -# method=contract.get_method_by_name("doMath"), -# sender=alice.address, -# app_id=app_client.app_id, -# args=[1, 2, "sum"], -# ) -# ) -# .execute() -# ) -# assert result.abi_results[0].return_value == DO_MATH_VALUE - - -# def test_add_method_with_txn_arg( -# algorand: AlgorandClient, contract: Contract, alice: AddressAndSigner, app_client: ApplicationClient -# ) -> None: -# pay_arg = PaymentParams(sender=alice.address, receiver=alice.address, amount=1) -# result = ( -# algorand.new_group() -# .add_payment(PaymentParams(sender=alice.address, amount=0, receiver=alice.address)) -# .add_method_call( -# AppMethodCallParams( -# method=contract.get_method_by_name("txnArg"), -# sender=alice.address, -# app_id=app_client.app_id, -# args=[pay_arg], -# ) -# ) -# .execute() -# ) -# assert result.abi_results[0].return_value == alice.address - - -# def test_add_method_call_with_method_call_arg( -# algorand: AlgorandClient, contract: Contract, alice: AddressAndSigner, app_client: ApplicationClient -# ) -> None: -# hello_world_call = AppMethodCallParams( -# method=contract.get_method_by_name("helloWorld"), sender=alice.address, app_id=app_client.app_id -# ) -# result = ( -# algorand.new_group() -# .add_method_call( -# AppMethodCallParams( -# method=contract.get_method_by_name("methodArg"), -# sender=alice.address, -# app_id=app_client.app_id, -# args=[hello_world_call], -# ) -# ) -# .execute() -# ) -# assert result.abi_results[0].return_value == "Hello, World!" -# assert result.abi_results[1].return_value == app_client.app_id - - -# def test_add_method_call_with_method_call_arg_with_txn_arg( -# algorand: AlgorandClient, contract: Contract, alice: AddressAndSigner, app_client: ApplicationClient -# ) -> None: -# pay_arg = PaymentParams(sender=alice.address, receiver=alice.address, amount=1) -# txn_arg_call = AppMethodCallParams( -# method=contract.get_method_by_name("txnArg"), sender=alice.address, app_id=app_client.app_id, args=[pay_arg] -# ) -# result = ( -# algorand.new_group() -# .add_method_call( -# AppMethodCallParams( -# method=contract.get_method_by_name("nestedTxnArg"), -# sender=alice.address, -# app_id=app_client.app_id, -# args=[txn_arg_call], -# ) -# ) -# .execute() -# ) -# assert result.abi_results[0].return_value == alice.address -# assert result.abi_results[1].return_value == app_client.app_id - - -# def test_add_method_call_with_two_method_call_args_with_txn_arg( -# algorand: AlgorandClient, contract: Contract, alice: AddressAndSigner, app_client: ApplicationClient -# ) -> None: -# pay_arg_1 = PaymentParams(sender=alice.address, receiver=alice.address, amount=1) -# txn_arg_call_1 = AppMethodCallParams( -# method=contract.get_method_by_name("txnArg"), -# sender=alice.address, -# app_id=app_client.app_id, -# args=[pay_arg_1], -# note=b"1", -# ) - -# pay_arg_2 = PaymentParams(sender=alice.address, receiver=alice.address, amount=2) -# txn_arg_call_2 = AppMethodCallParams( -# method=contract.get_method_by_name("txnArg"), sender=alice.address, app_id=app_client.app_id, args=[pay_arg_2] -# ) - -# result = ( -# algorand.new_group() -# .add_method_call( -# AppMethodCallParams( -# method=contract.get_method_by_name("doubleNestedTxnArg"), -# sender=alice.address, -# app_id=app_client.app_id, -# args=[txn_arg_call_1, txn_arg_call_2], -# ) -# ) -# .execute() -# ) -# assert result.abi_results[0].return_value == alice.address -# assert result.abi_results[1].return_value == alice.address -# assert result.abi_results[2].return_value == app_client.app_id diff --git a/tests/conftest.py b/tests/conftest.py index 18021c21..9499465e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,8 +6,9 @@ from typing import TYPE_CHECKING from uuid import uuid4 -import algosdk.transaction import pytest +from dotenv import load_dotenv + from algokit_utils import ( DELETABLE_TEMPLATE_NAME, UPDATABLE_TEMPLATE_NAME, @@ -16,20 +17,13 @@ ApplicationSpecification, EnsureBalanceParameters, ensure_funded, - get_account, - get_algod_client, - get_indexer_client, - get_kmd_client_from_algod_client, replace_template_variables, ) -from dotenv import load_dotenv - -from legacy_v2_tests import app_client_test +from algokit_utils.clients.algorand_client import AlgorandClient +from algokit_utils.transactions.transaction_composer import AssetCreateParams if TYPE_CHECKING: - from algosdk.kmd import KMDClient from algosdk.v2client.algod import AlgodClient - from algosdk.v2client.indexer import IndexerClient @pytest.fixture(autouse=True, scope="session") @@ -127,77 +121,30 @@ def is_opted_in(client_fixture: ApplicationClient) -> bool: 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(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() - - -@pytest.fixture() -def creator(algod_client: "AlgodClient") -> Account: - creator_name = get_unique_name() - return get_account(algod_client, creator_name) - - -@pytest.fixture(scope="session") -def funded_account(algod_client: "AlgodClient") -> Account: - creator_name = get_unique_name() - return get_account(algod_client, creator_name) - - -@pytest.fixture(scope="session") -def app_spec() -> ApplicationSpecification: - app_spec = app_client_test.app.build() - path = Path(__file__).parent / "app_client_test.json" - path.write_text(app_spec.to_json()) - return read_spec("app_client_test.json", deletable=True, updatable=True, template_values={"VERSION": 1}) - - -def generate_test_asset(algod_client: "AlgodClient", sender: Account, total: int | None) -> int: +def generate_test_asset(algorand: AlgorandClient, sender: Account, total: int | None) -> int: if total is None: total = math.floor(random.random() * 100) + 20 decimals = 0 asset_name = f"ASA ${math.floor(random.random() * 100) + 1}_${math.floor(random.random() * 100) + 1}_${total}" - params = algod_client.suggested_params() - - txn = algosdk.transaction.AssetConfigTxn( - sender=sender.address, - sp=params, - total=total * 10**decimals, - decimals=decimals, - default_frozen=False, - unit_name="", - asset_name=asset_name, - manager=sender.address, - reserve=sender.address, - freeze=sender.address, - clawback=sender.address, - url="https://path/to/my/asset/details", - metadata_hash=None, - note=None, - lease=None, - rekey_to=None, + create_result = algorand.send.asset_create( + AssetCreateParams( + sender=sender.address, + total=total, + decimals=decimals, + default_frozen=False, + unit_name="CFG", + asset_name=asset_name, + url="https://example.com", + manager=sender.address, + reserve=sender.address, + freeze=sender.address, + clawback=sender.address, + ) ) - signed_transaction = txn.sign(sender.private_key) - algod_client.send_transaction(signed_transaction) - ptx = algod_client.pending_transaction_info(txn.get_txid()) - - if isinstance(ptx, dict) and "asset-index" in ptx and isinstance(ptx["asset-index"], int): - return ptx["asset-index"] - else: - raise ValueError("Unexpected response from pending_transaction_info") + return int(create_result.confirmation["asset-index"]) # type: ignore[call-overload] def assure_funds(algod_client: "AlgodClient", account: Account) -> None: diff --git a/tests/test_transaction_composer.py b/tests/test_transaction_composer.py index 5ea937ec..a7096c83 100644 --- a/tests/test_transaction_composer.py +++ b/tests/test_transaction_composer.py @@ -1,6 +1,13 @@ from typing import TYPE_CHECKING import pytest +from algosdk.transaction import ( + ApplicationCreateTxn, + AssetConfigTxn, + AssetCreateTxn, + PaymentTxn, +) + from algokit_utils._legacy_v2.account import get_account from algokit_utils.clients.algorand_client import AlgorandClient from algokit_utils.models.account import Account @@ -13,27 +20,29 @@ SendAtomicTransactionComposerResults, TransactionComposer, ) -from algosdk.transaction import ( - ApplicationCreateTxn, - AssetConfigTxn, - AssetCreateTxn, - PaymentTxn, -) - from legacy_v2_tests.conftest import get_unique_name if TYPE_CHECKING: from algokit_utils.transactions.models import Arc2TransactionNote -@pytest.fixture() -def algorand(funded_account: Account) -> AlgorandClient: - client = AlgorandClient.default_local_net() - client.set_signer(sender=funded_account.address, signer=funded_account.signer) - return client +@pytest.fixture +def algorand() -> AlgorandClient: + return AlgorandClient.default_local_net() + + +@pytest.fixture +def funded_account(algorand: AlgorandClient) -> Account: + new_account = algorand.account.random() + dispenser = algorand.account.localnet_dispenser() + algorand.account.ensure_funded( + new_account, dispenser, AlgoAmount.from_algos(100), min_funding_increment=AlgoAmount.from_algos(1) + ) + algorand.set_signer(sender=new_account.address, signer=new_account.signer) + return new_account -@pytest.fixture() +@pytest.fixture def funded_secondary_account(algorand: AlgorandClient) -> Account: secondary_name = get_unique_name() return get_account(algorand.client.algod, secondary_name) diff --git a/tests/transactions/test_transaction_composer.py b/tests/transactions/test_transaction_composer.py index 619668e8..9217cc08 100644 --- a/tests/transactions/test_transaction_composer.py +++ b/tests/transactions/test_transaction_composer.py @@ -3,6 +3,14 @@ import algosdk import pytest +from algosdk.transaction import ( + ApplicationCallTxn, + ApplicationCreateTxn, + AssetConfigTxn, + AssetCreateTxn, + PaymentTxn, +) + from algokit_utils._legacy_v2.account import get_account from algokit_utils.clients.algorand_client import AlgorandClient from algokit_utils.models.account import Account @@ -16,28 +24,29 @@ SendAtomicTransactionComposerResults, TransactionComposer, ) -from algosdk.transaction import ( - ApplicationCallTxn, - ApplicationCreateTxn, - AssetConfigTxn, - AssetCreateTxn, - PaymentTxn, -) - from legacy_v2_tests.conftest import get_unique_name if TYPE_CHECKING: from algokit_utils.transactions.models import Arc2TransactionNote -@pytest.fixture() -def algorand(funded_account: Account) -> AlgorandClient: - client = AlgorandClient.default_local_net() - client.set_signer(sender=funded_account.address, signer=funded_account.signer) - return client +@pytest.fixture +def algorand() -> AlgorandClient: + return AlgorandClient.default_local_net() + +@pytest.fixture +def funded_account(algorand: AlgorandClient) -> Account: + new_account = algorand.account.random() + dispenser = algorand.account.localnet_dispenser() + algorand.account.ensure_funded( + new_account, dispenser, AlgoAmount.from_algos(100), min_funding_increment=AlgoAmount.from_algos(1) + ) + algorand.set_signer(sender=new_account.address, signer=new_account.signer) + return new_account -@pytest.fixture() + +@pytest.fixture def funded_secondary_account(algorand: AlgorandClient) -> Account: secondary_name = get_unique_name() return get_account(algorand.client.algod, secondary_name) @@ -82,7 +91,7 @@ def test_add_asset_create(algorand: AlgorandClient, funded_account: Account) -> composer.add_asset_create(params) built = composer.build_transactions() - response = composer.execute(max_rounds_to_wait=20) + response = composer.send(max_rounds_to_wait=20) created_asset = algorand.client.algod.asset_info( algorand.client.algod.pending_transaction_info(response.tx_ids[0])["asset-index"] # type: ignore[call-overload] )["params"] @@ -138,7 +147,7 @@ def test_add_asset_config(algorand: AlgorandClient, funded_account: Account, fun assert txn.index == asset_before_config_index assert txn.manager == funded_secondary_account.address - composer.execute(max_rounds_to_wait=20) + composer.send(max_rounds_to_wait=20) updated_asset = algorand.client.algod.asset_info(asset_id=asset_before_config_index)["params"] # type: ignore[call-overload] assert updated_asset["manager"] == funded_secondary_account.address @@ -165,7 +174,7 @@ def test_add_app_create(algorand: AlgorandClient, funded_account: Account) -> No assert txn.sender == funded_account.address assert txn.approval_program == b"\x06\x81\x01" assert txn.clear_program == b"\x06\x81\x01" - composer.execute(max_rounds_to_wait=20) + composer.send(max_rounds_to_wait=20) def test_add_app_call_method_call(algorand: AlgorandClient, funded_account: Account) -> None: @@ -173,8 +182,8 @@ def test_add_app_call_method_call(algorand: AlgorandClient, funded_account: Acco algod=algorand.client.algod, get_signer=lambda _: funded_account.signer, ) - approval_program = Path(Path(__file__).parent / "artifacts" / "hello_world" / "approval.teal").read_text() - clear_state_program = Path(Path(__file__).parent / "artifacts" / "hello_world" / "clear.teal").read_text() + approval_program = Path(Path(__file__).parent.parent / "artifacts" / "hello_world" / "approval.teal").read_text() + clear_state_program = Path(Path(__file__).parent.parent / "artifacts" / "hello_world" / "clear.teal").read_text() composer.add_app_create( AppCreateParams( sender=funded_account.address, @@ -183,7 +192,7 @@ def test_add_app_call_method_call(algorand: AlgorandClient, funded_account: Acco schema={"global_ints": 0, "global_bytes": 0, "local_ints": 0, "local_bytes": 0}, ) ) - response = composer.execute() + response = composer.send() app_id = algorand.client.algod.pending_transaction_info(response.tx_ids[0])["application-index"] # type: ignore[call-overload] composer = TransactionComposer( @@ -204,8 +213,8 @@ def test_add_app_call_method_call(algorand: AlgorandClient, funded_account: Acco assert isinstance(built.transactions[0], ApplicationCallTxn) txn = built.transactions[0] assert txn.sender == funded_account.address - response = composer.execute(max_rounds_to_wait=20) - assert response.returns[-1] == "Hello, world" + response = composer.send(max_rounds_to_wait=20) + assert response.returns[-1].return_value == "Hello, world" def test_simulate(algorand: AlgorandClient, funded_account: Account) -> None: diff --git a/tests/transactions/test_transaction_creator.py b/tests/transactions/test_transaction_creator.py index ec84d650..3c944041 100644 --- a/tests/transactions/test_transaction_creator.py +++ b/tests/transactions/test_transaction_creator.py @@ -2,6 +2,18 @@ import algosdk import pytest +from algosdk.transaction import ( + ApplicationCallTxn, + ApplicationCreateTxn, + AssetConfigTxn, + AssetCreateTxn, + AssetDestroyTxn, + AssetFreezeTxn, + AssetTransferTxn, + KeyregTxn, + PaymentTxn, +) + from algokit_utils._legacy_v2.account import get_account from algokit_utils.clients.algorand_client import AlgorandClient from algokit_utils.models.account import Account @@ -19,29 +31,26 @@ OnlineKeyRegistrationParams, PaymentParams, ) -from algosdk.transaction import ( - ApplicationCallTxn, - ApplicationCreateTxn, - AssetConfigTxn, - AssetCreateTxn, - AssetDestroyTxn, - AssetFreezeTxn, - AssetTransferTxn, - KeyregTxn, - PaymentTxn, -) - from legacy_v2_tests.conftest import get_unique_name -@pytest.fixture() -def algorand(funded_account: Account) -> AlgorandClient: - client = AlgorandClient.default_local_net() - client.set_signer(sender=funded_account.address, signer=funded_account.signer) - return client +@pytest.fixture +def algorand() -> AlgorandClient: + return AlgorandClient.default_local_net() + + +@pytest.fixture +def funded_account(algorand: AlgorandClient) -> Account: + new_account = algorand.account.random() + dispenser = algorand.account.localnet_dispenser() + algorand.account.ensure_funded( + new_account, dispenser, AlgoAmount.from_algos(100), min_funding_increment=AlgoAmount.from_algos(1) + ) + algorand.set_signer(sender=new_account.address, signer=new_account.signer) + return new_account -@pytest.fixture() +@pytest.fixture def funded_secondary_account(algorand: AlgorandClient, funded_account: Account) -> Account: secondary_name = get_unique_name() account = get_account(algorand.client.algod, secondary_name) @@ -210,8 +219,8 @@ def test_create_app_create_transaction(algorand: AlgorandClient, funded_account: def test_create_app_call_method_call_transaction(algorand: AlgorandClient, funded_account: Account) -> None: - approval_program = Path(Path(__file__).parent / "artifacts" / "hello_world" / "approval.teal").read_text() - clear_state_program = Path(Path(__file__).parent / "artifacts" / "hello_world" / "clear.teal").read_text() + approval_program = Path(Path(__file__).parent.parent / "artifacts" / "hello_world" / "approval.teal").read_text() + clear_state_program = Path(Path(__file__).parent.parent / "artifacts" / "hello_world" / "clear.teal").read_text() # First create the app create_result = algorand.send.app_create( @@ -222,7 +231,7 @@ def test_create_app_call_method_call_transaction(algorand: AlgorandClient, funde schema={"global_ints": 0, "global_bytes": 0, "local_ints": 0, "local_bytes": 0}, ) ) - app_id = algorand.client.algod.pending_transaction_info(create_result.tx_id)["application-index"] # type: ignore[call-overload] + app_id = algorand.client.algod.pending_transaction_info(create_result.tx_ids[0])["application-index"] # type: ignore[call-overload] # Then test creating a method call transaction result = algorand.create_transaction.app_call_method_call( diff --git a/tests/transactions/test_transaction_sender.py b/tests/transactions/test_transaction_sender.py index b8514cdb..def636fd 100644 --- a/tests/transactions/test_transaction_sender.py +++ b/tests/transactions/test_transaction_sender.py @@ -1,15 +1,18 @@ -from typing import TYPE_CHECKING, cast +from pathlib import Path from unittest.mock import MagicMock, patch +import algosdk import pytest -from algokit_utils import ( - Account, - get_account, -) + +from algokit_utils import Account +from algokit_utils._legacy_v2.application_specification import ApplicationSpecification from algokit_utils.applications.app_manager import AppManager from algokit_utils.assets.asset_manager import AssetManager +from algokit_utils.clients.algorand_client import AlgorandClient from algokit_utils.models.amount import AlgoAmount from algokit_utils.transactions.transaction_composer import ( + AppCallMethodCall, + AppCallParams, AppCreateParams, AssetConfigParams, AssetCreateParams, @@ -23,47 +26,86 @@ TransactionComposer, ) from algokit_utils.transactions.transaction_sender import AlgorandClientTransactionSender -from algosdk.transaction import ( - ApplicationCreateTxn, - AssetConfigTxn, - AssetCreateTxn, - AssetDestroyTxn, - AssetFreezeTxn, - AssetTransferTxn, - PaymentTxn, -) -from tests.conftest import get_unique_name -if TYPE_CHECKING: - import algosdk +@pytest.fixture +def algorand() -> AlgorandClient: + return AlgorandClient.default_local_net() + + +@pytest.fixture +def funded_account(algorand: AlgorandClient) -> Account: + new_account = algorand.account.random() + dispenser = algorand.account.localnet_dispenser() + algorand.account.ensure_funded( + new_account, dispenser, AlgoAmount.from_algos(100), min_funding_increment=AlgoAmount.from_algos(1) + ) + algorand.set_signer(sender=new_account.address, signer=new_account.signer) + return new_account -@pytest.fixture() +@pytest.fixture def sender(funded_account: Account) -> Account: return funded_account -@pytest.fixture() -def receiver(algod_client: "algosdk.v2client.algod.AlgodClient") -> Account: - return get_account(algod_client, get_unique_name()) +@pytest.fixture +def receiver(algorand: AlgorandClient) -> Account: + new_account = algorand.account.random() + dispenser = algorand.account.localnet_dispenser() + algorand.account.ensure_funded( + new_account, dispenser, AlgoAmount.from_algos(100), min_funding_increment=AlgoAmount.from_algos(1) + ) + return new_account + + +@pytest.fixture +def raw_hello_world_arc32_app_spec() -> str: + raw_json_spec = Path(__file__).parent.parent / "artifacts" / "hello_world" / "arc32_app_spec.json" + return raw_json_spec.read_text() + + +@pytest.fixture +def test_hello_world_arc32_app_spec() -> ApplicationSpecification: + raw_json_spec = Path(__file__).parent.parent / "artifacts" / "hello_world" / "arc32_app_spec.json" + return ApplicationSpecification.from_json(raw_json_spec.read_text()) + + +@pytest.fixture +def test_hello_world_arc32_app_id( + algorand: AlgorandClient, funded_account: Account, test_hello_world_arc32_app_spec: ApplicationSpecification +) -> int: + global_schema = test_hello_world_arc32_app_spec.global_state_schema + local_schema = test_hello_world_arc32_app_spec.local_state_schema + response = algorand.send.app_create( + AppCreateParams( + sender=funded_account.address, + approval_program=test_hello_world_arc32_app_spec.approval_program, + clear_state_program=test_hello_world_arc32_app_spec.clear_program, + schema={ + "global_ints": int(global_schema.num_uints) if global_schema.num_uints else 0, + "global_bytes": int(global_schema.num_byte_slices) if global_schema.num_byte_slices else 0, + "local_ints": int(local_schema.num_uints) if local_schema.num_uints else 0, + "local_bytes": int(local_schema.num_byte_slices) if local_schema.num_byte_slices else 0, + }, + ) + ) + return response.app_id -@pytest.fixture() -def transaction_sender( - algod_client: "algosdk.v2client.algod.AlgodClient", sender: Account -) -> AlgorandClientTransactionSender: +@pytest.fixture +def transaction_sender(algorand: AlgorandClient, sender: Account) -> AlgorandClientTransactionSender: def new_group() -> TransactionComposer: return TransactionComposer( - algod=algod_client, + algod=algorand.client.algod, get_signer=lambda _: sender.signer, ) return AlgorandClientTransactionSender( new_group=new_group, - asset_manager=AssetManager(algod_client, new_group), - app_manager=AppManager(algod_client), - algod_client=algod_client, + asset_manager=AssetManager(algorand.client.algod, new_group), + app_manager=AppManager(algorand.client.algod), + algod_client=algorand.client.algod, ) @@ -79,7 +121,8 @@ def test_payment(transaction_sender: AlgorandClientTransactionSender, sender: Ac assert len(result.tx_ids) == 1 assert result.confirmations[-1]["confirmed-round"] > 0 # type: ignore[call-overload] - txn = cast(PaymentTxn, result.transaction) + txn = result.transaction.payment + assert txn assert txn.sender == sender.address assert txn.receiver == receiver.address assert txn.amt == amount.micro_algos @@ -100,7 +143,8 @@ def test_asset_create(transaction_sender: AlgorandClientTransactionSender, sende result = transaction_sender.asset_create(params) assert len(result.tx_ids) == 1 assert result.confirmations[-1]["confirmed-round"] > 0 # type: ignore[call-overload] - txn = cast(AssetCreateTxn, result.transaction) + txn = result.transaction.asset_config + assert txn assert txn.sender == sender.address assert txn.total == total assert txn.decimals == 0 @@ -135,10 +179,12 @@ def test_asset_config(transaction_sender: AlgorandClientTransactionSender, sende result = transaction_sender.asset_config(config_params) assert len(result.tx_ids) == 1 - assert isinstance(result.transaction, AssetConfigTxn) - assert result.transaction.sender == sender.address - assert result.transaction.index == asset_id - assert result.transaction.manager == receiver.address + assert result.transaction.asset_config + txn = result.transaction.asset_config + assert txn + assert txn.sender == sender.address + assert txn.index == asset_id + assert txn.manager == receiver.address def test_asset_freeze( @@ -171,7 +217,9 @@ def test_asset_freeze( result = transaction_sender.asset_freeze(freeze_params) assert len(result.tx_ids) == 1 - txn = cast(AssetFreezeTxn, result.transaction) + assert result.transaction.asset_freeze + txn = result.transaction.asset_freeze + assert txn assert txn.sender == sender.address assert txn.index == asset_id assert txn.target == sender.address @@ -202,7 +250,8 @@ def test_asset_destroy(transaction_sender: AlgorandClientTransactionSender, send result = transaction_sender.asset_destroy(destroy_params) assert len(result.tx_ids) == 1 - txn = cast(AssetDestroyTxn, result.transaction) + txn = result.transaction.asset_config + assert txn assert txn.sender == sender.address assert txn.index == asset_id @@ -244,7 +293,8 @@ def test_asset_transfer( result = transaction_sender.asset_transfer(transfer_params) assert len(result.tx_ids) == 1 - txn = cast(AssetTransferTxn, result.transaction) + txn = result.transaction.asset_transfer + assert txn assert txn.sender == sender.address assert txn.index == asset_id assert txn.receiver == receiver.address @@ -275,7 +325,8 @@ def test_asset_opt_in(transaction_sender: AlgorandClientTransactionSender, sende result = transaction_sender.asset_opt_in(opt_in_params) assert len(result.tx_ids) == 1 - txn = cast(AssetTransferTxn, result.transaction) + assert result.transaction.asset_transfer + txn = result.transaction.asset_transfer assert txn.sender == receiver.address assert txn.index == asset_id assert txn.amount == 0 @@ -315,7 +366,8 @@ def test_asset_opt_out(transaction_sender: AlgorandClientTransactionSender, send ) result = transaction_sender.asset_opt_out(params=opt_out_params) - txn = cast(AssetTransferTxn, result.transaction) + assert result.transaction.asset_transfer + txn = result.transaction.asset_transfer assert txn.sender == receiver.address assert txn.index == asset_id assert txn.amount == 0 @@ -336,13 +388,40 @@ def test_app_create(transaction_sender: AlgorandClientTransactionSender, sender: result = transaction_sender.app_create(params) assert result.app_id > 0 assert result.app_address - txn = cast(ApplicationCreateTxn, result.transaction) + + assert result.transaction.application_call + txn = result.transaction.application_call assert txn.sender == sender.address assert txn.approval_program == b"\x06\x81\x01" assert txn.clear_program == b"\x06\x81\x01" -# TODO: add remaining app call and app method call tests +def test_app_call( + test_hello_world_arc32_app_id: int, transaction_sender: AlgorandClientTransactionSender, sender: Account +) -> None: + params = AppCallParams( + app_id=test_hello_world_arc32_app_id, + sender=sender.address, + args=[b"\x02\xbe\xce\x11", b"test"], + ) + + result = transaction_sender.app_call(params) + assert not result.return_value # TODO: improve checks + + +def test_app_call_method_call( + test_hello_world_arc32_app_id: int, transaction_sender: AlgorandClientTransactionSender, sender: Account +) -> None: + params = AppCallMethodCall( + app_id=test_hello_world_arc32_app_id, + sender=sender.address, + method=algosdk.abi.Method.from_signature("hello(string)string"), + args=["test"], + ) + + result = transaction_sender.app_call_method_call(params) + assert result.return_value + assert result.return_value.return_value == "Hello2, test" @patch("logging.Logger.debug") diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 00000000..612ea60d --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,29 @@ +from pathlib import Path + +from algokit_utils._legacy_v2.application_specification import ApplicationSpecification +from algokit_utils.applications.app_manager import AppManager +from algokit_utils.models.application import DELETABLE_TEMPLATE_NAME, UPDATABLE_TEMPLATE_NAME + + +def load_arc32_spec( + path: Path, + *, + updatable: bool | None = None, + deletable: bool | None = None, + template_values: dict | None = None, +) -> ApplicationSpecification: + spec = ApplicationSpecification.from_json(path.read_text(encoding="utf-8")) + + template_variables = template_values or {} + if updatable is not None: + template_variables["UPDATABLE"] = int(updatable) + + if deletable is not None: + template_variables["DELETABLE"] = int(deletable) + + spec.approval_program = ( + AppManager.replace_template_variables(spec.approval_program, template_variables) + .replace(f"// {UPDATABLE_TEMPLATE_NAME}", "// updatable") + .replace(f"// {DELETABLE_TEMPLATE_NAME}", "// deletable") + ) + return spec From 18744fe0eb9a1bcfa695fe4e274139d9737755f5 Mon Sep 17 00:00:00 2001 From: Altynbek Orumbayev Date: Wed, 11 Dec 2024 18:33:01 +0100 Subject: [PATCH 5/6] chore: expose new interfaces --- src/algokit_utils/__init__.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/algokit_utils/__init__.py b/src/algokit_utils/__init__.py index 5b3a1647..8f06e519 100644 --- a/src/algokit_utils/__init__.py +++ b/src/algokit_utils/__init__.py @@ -81,6 +81,15 @@ is_mainnet, is_testnet, ) + +# New interfaces +from algokit_utils.accounts.account_manager import AccountManager +from algokit_utils.accounts.kmd_account_manager import KmdAccountManager +from algokit_utils.applications.app_client import AppClient +from algokit_utils.applications.app_factory import AppFactory +from algokit_utils.assets.asset_manager import AssetManager +from algokit_utils.clients.algorand_client import AlgorandClient +from algokit_utils.clients.client_manager import ClientManager from algokit_utils.clients.dispenser_api_client import ( DISPENSER_ACCESS_TOKEN_KEY, DISPENSER_REQUEST_TIMEOUT, @@ -90,6 +99,7 @@ ) from algokit_utils.models.account import Account from algokit_utils.models.application import DELETABLE_TEMPLATE_NAME, UPDATABLE_TEMPLATE_NAME +from algokit_utils.transactions.transaction_composer import TransactionComposer __all__ = [ "DELETABLE_TEMPLATE_NAME", @@ -105,15 +115,21 @@ "ABIMethod", "ABITransactionResponse", "Account", + "AccountManager", "AlgoClientConfig", + "AlgorandClient", + "AppClient", "AppDeployMetaData", + "AppFactory", "AppLookup", "AppMetaData", "AppReference", "AppSpecStateDict", "ApplicationClient", "ApplicationSpecification", + "AssetManager", "CallConfig", + "ClientManager", "CommonCallParameters", "CommonCallParametersDict", "CreateCallParameters", @@ -131,6 +147,7 @@ "DispenserLimitResponse", "EnsureBalanceParameters", "EnsureFundedResponse", + "KmdAccountManager", "LogicError", "MethodConfigDict", "MethodHints", @@ -145,6 +162,7 @@ "TemplateValueDict", "TemplateValueMapping", "TestNetDispenserApiClient", + "TransactionComposer", "TransactionParameters", "TransactionParametersDict", "TransactionResponse", From 6bf71aab40cad5d251b370d806d276867272b69d Mon Sep 17 00:00:00 2001 From: Al Date: Fri, 13 Dec 2024 20:31:10 +0100 Subject: [PATCH 6/6] refactor: further aligning composer class; initial batch of resource population tests (#125) * fix: configure method tweaks; fixing appdeployresult init * test: adding first half of resource population tests; improving composer txn generation --- pyproject.toml | 4 + src/algokit_utils/accounts/account_manager.py | 141 ++++++---- .../applications/app_deployer.py | 13 +- src/algokit_utils/applications/app_manager.py | 12 +- src/algokit_utils/config.py | 12 +- src/algokit_utils/models/account.py | 76 ++++- .../transactions/transaction_composer.py | 260 ++++++++++-------- .../transactions/transaction_sender.py | 6 +- src/algokit_utils/transactions/utils.py | 157 ++++++++--- tests/artifacts/resource-packer/.gitignore | 3 + .../resource-packer/ExternalApp.arc32.json | 140 ++++++++++ .../resource-packer/ExternalAppV8.arc32.json | 69 +++++ .../ResourcePackerv8.arc32.json | 173 ++++++++++++ .../ResourcePackerv9.arc32.json | 173 ++++++++++++ .../resource-packer/resource-packer.algo.ts | 158 +++++++++++ tests/test_transaction_composer.py | 219 --------------- tests/transactions/test_resource_packing.py | 168 +++++++++++ .../transactions/test_transaction_composer.py | 127 ++++++++- .../transactions/test_transaction_creator.py | 3 +- tests/transactions/test_transaction_sender.py | 2 + 20 files changed, 1464 insertions(+), 452 deletions(-) create mode 100644 tests/artifacts/resource-packer/.gitignore create mode 100644 tests/artifacts/resource-packer/ExternalApp.arc32.json create mode 100644 tests/artifacts/resource-packer/ExternalAppV8.arc32.json create mode 100644 tests/artifacts/resource-packer/ResourcePackerv8.arc32.json create mode 100644 tests/artifacts/resource-packer/ResourcePackerv9.arc32.json create mode 100644 tests/artifacts/resource-packer/resource-packer.algo.ts delete mode 100644 tests/test_transaction_composer.py create mode 100644 tests/transactions/test_resource_packing.py diff --git a/pyproject.toml b/pyproject.toml index f5efc256..e663f468 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -164,6 +164,10 @@ untyped_calls_exclude = [ module = ["algosdk", "algosdk.*"] disallow_untyped_calls = false +[[tool.mypy.overrides]] +module = ["tests.transactions.test_transaction_composer"] +disable_error_code = ["call-overload", "union-attr"] + [tool.semantic_release] version_toml = "pyproject.toml:tool.poetry.version" remove_dist = false diff --git a/src/algokit_utils/accounts/account_manager.py b/src/algokit_utils/accounts/account_manager.py index d997a211..598676b1 100644 --- a/src/algokit_utils/accounts/account_manager.py +++ b/src/algokit_utils/accounts/account_manager.py @@ -4,16 +4,16 @@ from typing import Any from algosdk import mnemonic -from algosdk.atomic_transaction_composer import AccountTransactionSigner, TransactionSigner +from algosdk.atomic_transaction_composer import LogicSigTransactionSigner, TransactionSigner from algosdk.mnemonic import to_private_key -from algosdk.transaction import SuggestedParams +from algosdk.transaction import LogicSigAccount, SuggestedParams from typing_extensions import Self from algokit_utils.accounts.kmd_account_manager import KmdAccountManager from algokit_utils.clients.client_manager import ClientManager from algokit_utils.clients.dispenser_api_client import DispenserAssetName, TestNetDispenserApiClient from algokit_utils.config import config -from algokit_utils.models.account import DISPENSER_ACCOUNT_NAME, Account +from algokit_utils.models.account import DISPENSER_ACCOUNT_NAME, Account, MultiSigAccount, MultisigMetadata from algokit_utils.models.amount import AlgoAmount from algokit_utils.transactions.transaction_composer import ( PaymentParams, @@ -52,7 +52,7 @@ def __init__(self, client_manager: ClientManager): """ self._client_manager = client_manager self._kmd_account_manager = KmdAccountManager(client_manager) - self._accounts = dict[str, Account]() + self._signers = dict[str, TransactionSigner]() self._default_signer: TransactionSigner | None = None def set_default_signer(self, signer: TransactionSigner) -> Self: @@ -73,17 +73,26 @@ def set_signer(self, sender: str, signer: TransactionSigner) -> Self: :param signer: The signer to sign transactions with for the given sender :return: The AccountCreator instance for method chaining """ - if isinstance(signer, AccountTransactionSigner): - self._accounts[sender] = Account(private_key=signer.private_key) + self._signers[sender] = signer return self def get_account(self, sender: str) -> Account: - account = self._accounts.get(sender) + account = self._signers.get(sender) if not account: raise ValueError(f"No account found for address {sender}") + if not isinstance(account, Account): + raise ValueError(f"Account {sender} is not a regular account") return account - def get_signer(self, sender: str | Account) -> TransactionSigner: + def get_logic_sig_account(self, sender: str) -> LogicSigAccount: + account = self._signers.get(sender) + if not account: + raise ValueError(f"No account found for address {sender}") + if not isinstance(account, LogicSigAccount): + raise ValueError(f"Account {sender} is not a logic sig account") + return account + + def get_signer(self, sender: str | Account | LogicSigAccount) -> TransactionSigner: """ Returns the `TransactionSigner` for the given sender address. @@ -92,8 +101,7 @@ def get_signer(self, sender: str | Account) -> TransactionSigner: :param sender: The sender address :return: The `TransactionSigner` or throws an error if not found """ - account = self._accounts.get(self._get_address(sender)) - signer = account.signer if account else self._default_signer + signer = self._signers.get(self._get_address(sender)) if not signer: raise ValueError(f"No signer found for address {sender}") return signer @@ -109,29 +117,48 @@ def get_information(self, sender: str | Account) -> dict[str, Any]: assert isinstance(info, dict) return info - def from_mnemonic(self, mnemonic: str) -> Account: - private_key = to_private_key(mnemonic) + def _register_account(self, private_key: str) -> Account: + """Helper method to create and register an account with its signer. + + Args: + private_key: The private key for the account + + Returns: + The registered Account instance + """ account = Account(private_key=private_key) - self._accounts[account.address] = account - self.set_signer(account.address, AccountTransactionSigner(private_key=private_key)) + self._signers[account.address] = account.signer return account + def _register_logic_sig(self, program: bytes, args: list[bytes] | None = None) -> LogicSigAccount: + logic_sig = LogicSigAccount(program, args) + self._signers[logic_sig.address()] = LogicSigTransactionSigner(logic_sig) + return logic_sig + + def _register_multi_sig( + self, version: int, threshold: int, addrs: list[str], signing_accounts: list[Account] + ) -> MultiSigAccount: + msig_account = MultiSigAccount( + MultisigMetadata(version=version, threshold=threshold, addresses=addrs), + signing_accounts, + ) + self._signers[str(msig_account.address)] = msig_account.signer + return msig_account + + def from_mnemonic(self, mnemonic: str) -> Account: + private_key = to_private_key(mnemonic) + return self._register_account(private_key) + def from_environment(self, name: str, fund_with: AlgoAmount | None = None) -> Account: account_mnemonic = os.getenv(f"{name.upper()}_MNEMONIC") if account_mnemonic: private_key = mnemonic.to_private_key(account_mnemonic) - account = Account(private_key=private_key) - self._accounts[account.address] = account - self.set_signer(account.address, AccountTransactionSigner(private_key=private_key)) - return account + return self._register_account(private_key) if self._client_manager.is_local_net(): kmd_account = self._kmd_account_manager.get_or_create_wallet_account(name, fund_with) - account = Account(private_key=kmd_account.private_key) - self._accounts[account.address] = account - self.set_signer(account.address, AccountTransactionSigner(private_key=kmd_account.private_key)) - return account + return self._register_account(kmd_account.private_key) raise ValueError(f"Missing environment variable {name.upper()}_MNEMONIC when looking for account {name}") @@ -142,14 +169,38 @@ def from_kmd( if not kmd_account: raise ValueError(f"Unable to find KMD account {name}{' with predicate' if predicate else ''}") - account = Account(private_key=kmd_account.private_key) - self._accounts[account.address] = account - self.set_signer(account.address, AccountTransactionSigner(private_key=kmd_account.private_key)) - return account + return self._register_account(kmd_account.private_key) + + def logic_sig(self, program: bytes, args: list[bytes] | None = None) -> LogicSigAccount: + return self._register_logic_sig(program, args) + + def multi_sig( + self, version: int, threshold: int, addrs: list[str], signing_accounts: list[Account] + ) -> MultiSigAccount: + return self._register_multi_sig(version, threshold, addrs, signing_accounts) + + def random(self) -> Account: + """ + Tracks and returns a new, random Algorand account. + + :return: The account + """ + account = Account.new_account() + return self._register_account(account.private_key) + + def localnet_dispenser(self) -> Account: + kmd_account = self._kmd_account_manager.get_localnet_dispenser_account() + return self._register_account(kmd_account.private_key) + + def dispenser_from_environment(self) -> Account: + name = os.getenv(f"{DISPENSER_ACCOUNT_NAME}_MNEMONIC") + if name: + return self.from_environment(DISPENSER_ACCOUNT_NAME) + return self.localnet_dispenser() def rekeyed(self, sender: Account | str, account: Account) -> Account: sender_address = sender.address if isinstance(sender, Account) else sender - self._accounts[sender_address] = account + self._signers[sender_address] = account.signer return Account(address=sender_address, private_key=account.private_key) def rekey_account( # noqa: PLR0913 @@ -223,30 +274,6 @@ def rekey_account( # noqa: PLR0913 return result - def random(self) -> Account: - """ - Tracks and returns a new, random Algorand account. - - :return: The account - """ - account = Account.new_account() - self._accounts[account.address] = account - self.set_signer(account.address, AccountTransactionSigner(private_key=account.private_key)) - return account - - def localnet_dispenser(self) -> Account: - kmd_account = self._kmd_account_manager.get_localnet_dispenser_account() - account = Account(private_key=kmd_account.private_key) - self._accounts[account.address] = account - self.set_signer(account.address, AccountTransactionSigner(private_key=kmd_account.private_key)) - return account - - def dispenser_from_environment(self) -> Account: - name = os.getenv(f"{DISPENSER_ACCOUNT_NAME}_MNEMONIC") - if name: - return self.from_environment(DISPENSER_ACCOUNT_NAME) - return self.localnet_dispenser() - def ensure_funded( # noqa: PLR0913 self, account_to_fund: str | Account, @@ -448,8 +475,16 @@ def ensure_funded_from_testnet_dispenser_api( amount_funded=AlgoAmount.from_micro_algo(result.amount), ) - def _get_address(self, sender: str | Account) -> str: - return sender.address if isinstance(sender, Account) else sender + def _get_address(self, sender: str | Account | LogicSigAccount) -> str: + match sender: + case Account(): + return sender.address + case LogicSigAccount(): + return sender.address() + case str(): + return sender + case _: + raise ValueError(f"Unknown sender type: {type(sender)}") def _get_composer(self, get_suggested_params: Callable[[], SuggestedParams] | None = None) -> TransactionComposer: if get_suggested_params is None: diff --git a/src/algokit_utils/applications/app_deployer.py b/src/algokit_utils/applications/app_deployer.py index 357d808c..c3cc5853 100644 --- a/src/algokit_utils/applications/app_deployer.py +++ b/src/algokit_utils/applications/app_deployer.py @@ -268,12 +268,13 @@ def deploy(self, deployment: AppDeployParams) -> AppDeployResult: clear_program=clear_program, ) - return AppDeployResult( - **existing_app.__dict__, - operation_performed=OperationPerformed.Nothing, - app_id=existing_app.app_id, - app_address=existing_app.app_address, - ) + existing_app_dict = existing_app.__dict__ + existing_app_dict["operation_performed"] = OperationPerformed.Nothing + existing_app_dict["app_id"] = existing_app.app_id + existing_app_dict["app_address"] = existing_app.app_address + + logger.debug("No detected changes in app, nothing to do.", suppress_log=deployment.suppress_log) + return AppDeployResult(**existing_app_dict) def _create_app( self, diff --git a/src/algokit_utils/applications/app_manager.py b/src/algokit_utils/applications/app_manager.py index e282ede7..9ad6c5fe 100644 --- a/src/algokit_utils/applications/app_manager.py +++ b/src/algokit_utils/applications/app_manager.py @@ -54,14 +54,22 @@ class DataTypeFlag(IntEnum): class BoxReference(AlgosdkBoxReference): - def __init__(self, app_id: int, name: bytes): - super().__init__(app_index=app_id, name=name) + def __init__(self, app_id: int, name: bytes | str): + super().__init__(app_index=app_id, name=self._b64_decode(name)) def __eq__(self, other: object) -> bool: if isinstance(other, (BoxReference | AlgosdkBoxReference)): return self.app_index == other.app_index and self.name == other.name return False + def _b64_decode(self, value: str | bytes) -> bytes: + if isinstance(value, str): + try: + return base64.b64decode(value) + except Exception: + return value.encode("utf-8") + return value + def _is_valid_token_character(char: str) -> bool: return char.isalnum() or char == "_" diff --git a/src/algokit_utils/config.py b/src/algokit_utils/config.py index f76704ce..eb788910 100644 --- a/src/algokit_utils/config.py +++ b/src/algokit_utils/config.py @@ -127,11 +127,12 @@ def with_debug(self, func: Callable[[], str | None]) -> None: def configure( self, *, - debug: bool, + debug: bool | None = None, project_root: Path | None = None, trace_all: bool = False, trace_buffer_size_mb: float = 256, max_search_depth: int = 10, + populate_app_call_resources: bool = False, ) -> None: """ Configures various settings for the application. @@ -153,16 +154,17 @@ def configure( None """ - self._debug = debug - - if project_root: + if debug is not None: + self._debug = debug + if project_root is not None: self._project_root = project_root.resolve(strict=True) - elif debug and ALGOKIT_PROJECT_ROOT: + elif debug is not None and ALGOKIT_PROJECT_ROOT: self._project_root = Path(ALGOKIT_PROJECT_ROOT).resolve(strict=True) self._trace_all = trace_all self._trace_buffer_size_mb = trace_buffer_size_mb self._max_search_depth = max_search_depth + self._populate_app_call_resources = populate_app_call_resources config = UpdatableConfig() diff --git a/src/algokit_utils/models/account.py b/src/algokit_utils/models/account.py index f83cc1e2..8b5da485 100644 --- a/src/algokit_utils/models/account.py +++ b/src/algokit_utils/models/account.py @@ -1,7 +1,9 @@ import dataclasses import algosdk -from algosdk.atomic_transaction_composer import AccountTransactionSigner +import algosdk.atomic_transaction_composer +from algosdk.atomic_transaction_composer import AccountTransactionSigner, TransactionSigner +from algosdk.transaction import Multisig, MultisigTransaction DISPENSER_ACCOUNT_NAME = "DISPENSER" @@ -35,3 +37,75 @@ def signer(self) -> AccountTransactionSigner: def new_account() -> "Account": private_key, address = algosdk.account.generate_account() return Account(private_key=private_key) + + +@dataclasses.dataclass(kw_only=True) +class MultisigMetadata: + version: int + threshold: int + addresses: list[str] + + +@dataclasses.dataclass(kw_only=True) +class MultiSigAccount: + """Account wrapper that supports partial or full multisig signing.""" + + _params: MultisigMetadata + _signing_accounts: list[Account] + _addr: str + _signer: TransactionSigner + _multisig: Multisig + + def __init__(self, multisig_params: MultisigMetadata, signing_accounts: list[Account]) -> None: + """Initialize a new multisig account. + + Args: + multisig_params: The parameters for the multisig account + signing_accounts: The list of accounts that can sign + """ + self._params = multisig_params + self._signing_accounts = signing_accounts + self._multisig = Multisig(multisig_params.version, multisig_params.threshold, multisig_params.addresses) + self._addr = str(self._multisig.address()) + self._signer = algosdk.atomic_transaction_composer.MultisigTransactionSigner( + self._multisig, + [account.private_key for account in signing_accounts], + ) + + @property + def params(self) -> MultisigMetadata: + """The parameters for the multisig account.""" + return self._params + + @property + def signing_accounts(self) -> list[Account]: + """The list of accounts that are present to sign.""" + return self._signing_accounts + + @property + def address(self) -> str: + """The address of the multisig account.""" + return self._addr + + @property + def signer(self) -> TransactionSigner: + """The transaction signer for this multisig account.""" + return self._signer + + def sign(self, transaction: algosdk.transaction.Transaction) -> MultisigTransaction: + """Sign the given transaction. + + Args: + transaction: Either a transaction object or a raw, partially signed transaction + + Returns: + The transaction signed by the present signers + """ + msig_txn = MultisigTransaction( + transaction, + self._multisig, + ) + for signer in self._signing_accounts: + msig_txn.sign(signer.private_key) + + return msig_txn diff --git a/src/algokit_utils/transactions/transaction_composer.py b/src/algokit_utils/transactions/transaction_composer.py index 7d66c939..ccb6e199 100644 --- a/src/algokit_utils/transactions/transaction_composer.py +++ b/src/algokit_utils/transactions/transaction_composer.py @@ -274,7 +274,7 @@ class AppCallParams(CommonTxnParams, SenderParam): :param box_references: Box references. """ - on_complete: OnComplete | None = None + on_complete: OnComplete app_id: int | None = None approval_program: str | bytes | None = None clear_state_program: str | bytes | None = None @@ -372,6 +372,7 @@ class AppMethodCall(CommonTxnParams, SenderParam): app_references: list[int] | None = None asset_references: list[int] | None = None box_references: list[BoxReference] | None = None + schema: dict[str, int] | None = None @dataclass(kw_only=True, frozen=True) @@ -560,7 +561,8 @@ def send_atomic_transaction_composer( # noqa: C901, PLR0912 transactions_with_signer = atc.build_group() if populate_resources or ( - config.populate_app_call_resource + populate_resources is None + and config.populate_app_call_resource and any(isinstance(t.txn, algosdk.transaction.ApplicationCallTxn) for t in transactions_with_signer) ): atc = populate_app_call_resources(atc, algod) @@ -971,36 +973,32 @@ def _build_atc(self, atc: AtomicTransactionComposer) -> list[TransactionWithSign def _common_txn_build_step( self, + build_txn: Callable[[dict], algosdk.transaction.Transaction], params: CommonTxnParams, - txn: algosdk.transaction.Transaction, - suggested_params: algosdk.transaction.SuggestedParams, + txn_params: dict, ) -> algosdk.transaction.Transaction: + # Clone suggested params + txn_params["sp"] = ( + algosdk.transaction.SuggestedParams(**txn_params["sp"].__dict__) if "sp" in txn_params else None + ) + if params.lease: - txn.lease = encode_lease(params.lease) + txn_params["lease"] = encode_lease(params.lease) if params.rekey_to: - txn.rekey_to = params.rekey_to + txn_params["rekey_to"] = params.rekey_to if params.note: - txn.note = params.note - - if params.first_valid_round: - txn.first_valid_round = params.first_valid_round + txn_params["note"] = params.note - if params.last_valid_round: - txn.last_valid_round = params.last_valid_round - else: - txn.last_valid_round = txn.first_valid_round + (params.validity_window or self.default_validity_window) + if params.static_fee is not None and txn_params["sp"]: + txn_params["sp"].fee = params.static_fee.micro_algos + txn_params["sp"].flat_fee = True - if params.static_fee is not None and params.extra_fee is not None: - raise ValueError("Cannot set both static_fee and extra_fee") + txn = build_txn(txn_params) - if params.static_fee is not None: - txn.fee = params.static_fee.micro_algos - else: - txn.fee = txn.estimate_size() * suggested_params.fee or algosdk.constants.min_txn_fee - if params.extra_fee: - txn.fee += params.extra_fee + if params.extra_fee: + txn.fee += params.extra_fee.micro_algos - if params.max_fee is not None and txn.fee > params.max_fee: + if params.max_fee and txn.fee > params.max_fee.micro_algos: raise ValueError(f"Transaction fee {txn.fee} is greater than max_fee {params.max_fee}") return txn @@ -1064,62 +1062,80 @@ def _build_method_call( # noqa: C901, PLR0912 method_atc = AtomicTransactionComposer() - method_atc.add_method_call( - app_id=params.app_id or 0, - method=params.method, - sender=params.sender, - sp=suggested_params, - signer=params.signer or self.get_signer(params.sender), - method_args=method_args, - on_complete=params.on_complete or algosdk.transaction.OnComplete.NoOpOC, - note=params.note, - lease=params.lease, - boxes=[AppManager.get_box_reference(ref) for ref in params.box_references] + txn_params = { + "app_id": params.app_id or 0, + "method": params.method, + "sender": params.sender, + "sp": suggested_params, + "signer": params.signer or self.get_signer(params.sender), + "method_args": method_args, + "on_complete": params.on_complete or algosdk.transaction.OnComplete.NoOpOC, + "note": params.note, + "lease": params.lease, + "boxes": [AppManager.get_box_reference(ref) for ref in params.box_references] if params.box_references else None, - foreign_apps=params.app_references, - foreign_assets=params.asset_references, - accounts=params.account_references, - approval_program=params.approval_program if hasattr(params, "approval_program") else None, # type: ignore[arg-type] - clear_program=params.clear_state_program if hasattr(params, "clear_state_program") else None, # type: ignore[arg-type] - rekey_to=params.rekey_to, - ) + "foreign_apps": params.app_references, + "foreign_assets": params.asset_references, + "accounts": params.account_references, + "global_schema": algosdk.transaction.StateSchema( + num_uints=params.schema.get("global_ints", 0), + num_byte_slices=params.schema.get("global_bytes", 0), + ) + if params.schema + else None, + "local_schema": algosdk.transaction.StateSchema( + num_uints=params.schema.get("local_ints", 0), + num_byte_slices=params.schema.get("local_bytes", 0), + ) + if params.schema + else None, + "approval_program": params.approval_program if hasattr(params, "approval_program") else None, + "clear_program": params.clear_state_program if hasattr(params, "clear_state_program") else None, + "rekey_to": params.rekey_to, + } + + def _add_method_call_and_return_txn(x: dict) -> algosdk.transaction.Transaction: + method_atc.add_method_call(**x) + return method_atc.build_group()[-1].txn + + self._common_txn_build_step(lambda x: _add_method_call_and_return_txn(x), params, txn_params) return self._build_atc(method_atc) def _build_payment( self, params: PaymentParams, suggested_params: algosdk.transaction.SuggestedParams ) -> algosdk.transaction.Transaction: - txn = algosdk.transaction.PaymentTxn( - sender=params.sender, - sp=suggested_params, - receiver=params.receiver, - amt=params.amount.micro_algos, - close_remainder_to=params.close_remainder_to, - ) + txn_params = { + "sender": params.sender, + "sp": suggested_params, + "receiver": params.receiver, + "amt": params.amount.micro_algos, + "close_remainder_to": params.close_remainder_to, + } - return self._common_txn_build_step(params, txn, suggested_params) + return self._common_txn_build_step(lambda x: algosdk.transaction.PaymentTxn(**x), params, txn_params) def _build_asset_create( self, params: AssetCreateParams, suggested_params: algosdk.transaction.SuggestedParams ) -> algosdk.transaction.Transaction: - txn = algosdk.transaction.AssetCreateTxn( - sender=params.sender, - sp=suggested_params, - total=params.total, - default_frozen=params.default_frozen or False, - unit_name=params.unit_name or "", - asset_name=params.asset_name or "", - manager=params.manager, - reserve=params.reserve, - freeze=params.freeze, - clawback=params.clawback, - url=params.url or "", - metadata_hash=params.metadata_hash, - decimals=params.decimals or 0, - ) + txn_params = { + "sender": params.sender, + "sp": suggested_params, + "total": params.total, + "default_frozen": params.default_frozen or False, + "unit_name": params.unit_name or "", + "asset_name": params.asset_name or "", + "manager": params.manager, + "reserve": params.reserve, + "freeze": params.freeze, + "clawback": params.clawback, + "url": params.url or "", + "metadata_hash": params.metadata_hash, + "decimals": params.decimals or 0, + } - return self._common_txn_build_step(params, txn, suggested_params) + return self._common_txn_build_step(lambda x: algosdk.transaction.AssetCreateTxn(**x), params, txn_params) def _build_app_call( self, @@ -1158,6 +1174,8 @@ def _build_app_call( "clear_program": clear_program, } + txn_params = {**sdk_params, "index": app_id} + if not app_id and isinstance(params, AppCreateParams): if not sdk_params["approval_program"] or not sdk_params["clear_program"]: raise ValueError("approval_program and clear_program are required for application creation") @@ -1165,98 +1183,96 @@ def _build_app_call( if not params.schema: raise ValueError("schema is required for application creation") - txn = algosdk.transaction.ApplicationCreateTxn( - **sdk_params, - global_schema=algosdk.transaction.StateSchema( + txn_params = { + **txn_params, + "global_schema": algosdk.transaction.StateSchema( num_uints=params.schema.get("global_ints", 0), num_byte_slices=params.schema.get("global_bytes", 0), ), - local_schema=algosdk.transaction.StateSchema( + "local_schema": algosdk.transaction.StateSchema( num_uints=params.schema.get("local_ints", 0), num_byte_slices=params.schema.get("local_bytes", 0), ), - extra_pages=params.extra_program_pages + "extra_pages": params.extra_program_pages or math.floor((approval_program_len + clear_program_len) / algosdk.constants.APP_PAGE_MAX_SIZE) if params.extra_program_pages else 0, - ) - else: - txn = algosdk.transaction.ApplicationCallTxn(**sdk_params, index=app_id) # type: ignore[assignment] + } - return self._common_txn_build_step(params, txn, suggested_params) + return self._common_txn_build_step(lambda x: algosdk.transaction.ApplicationCallTxn(**x), params, txn_params) def _build_asset_config( self, params: AssetConfigParams, suggested_params: algosdk.transaction.SuggestedParams ) -> algosdk.transaction.Transaction: - txn = algosdk.transaction.AssetConfigTxn( - sender=params.sender, - sp=suggested_params, - index=params.asset_id, - manager=params.manager, - reserve=params.reserve, - freeze=params.freeze, - clawback=params.clawback, - strict_empty_address_check=False, - ) + txn_params = { + "sender": params.sender, + "sp": suggested_params, + "index": params.asset_id, + "manager": params.manager, + "reserve": params.reserve, + "freeze": params.freeze, + "clawback": params.clawback, + "strict_empty_address_check": False, + } - return self._common_txn_build_step(params, txn, suggested_params) + return self._common_txn_build_step(lambda x: algosdk.transaction.AssetConfigTxn(**x), params, txn_params) def _build_asset_destroy( self, params: AssetDestroyParams, suggested_params: algosdk.transaction.SuggestedParams ) -> algosdk.transaction.Transaction: - txn = algosdk.transaction.AssetDestroyTxn( - sender=params.sender, - sp=suggested_params, - index=params.asset_id, - ) + txn_params = { + "sender": params.sender, + "sp": suggested_params, + "index": params.asset_id, + } - return self._common_txn_build_step(params, txn, suggested_params) + return self._common_txn_build_step(lambda x: algosdk.transaction.AssetDestroyTxn(**x), params, txn_params) def _build_asset_freeze( self, params: AssetFreezeParams, suggested_params: algosdk.transaction.SuggestedParams ) -> algosdk.transaction.Transaction: - txn = algosdk.transaction.AssetFreezeTxn( - sender=params.sender, - sp=suggested_params, - index=params.asset_id, - target=params.account, - new_freeze_state=params.frozen, - ) + txn_params = { + "sender": params.sender, + "sp": suggested_params, + "index": params.asset_id, + "target": params.account, + "new_freeze_state": params.frozen, + } - return self._common_txn_build_step(params, txn, suggested_params) + return self._common_txn_build_step(lambda x: algosdk.transaction.AssetFreezeTxn(**x), params, txn_params) def _build_asset_transfer( self, params: AssetTransferParams, suggested_params: algosdk.transaction.SuggestedParams ) -> algosdk.transaction.Transaction: - txn = algosdk.transaction.AssetTransferTxn( - sender=params.sender, - sp=suggested_params, - receiver=params.receiver, - amt=params.amount, - index=params.asset_id, - close_assets_to=params.close_asset_to, - revocation_target=params.clawback_target, - ) + txn_params = { + "sender": params.sender, + "sp": suggested_params, + "receiver": params.receiver, + "amt": params.amount, + "index": params.asset_id, + "close_assets_to": params.close_asset_to, + "revocation_target": params.clawback_target, + } - return self._common_txn_build_step(params, txn, suggested_params) + return self._common_txn_build_step(lambda x: algosdk.transaction.AssetTransferTxn(**x), params, txn_params) def _build_key_reg( self, params: OnlineKeyRegistrationParams, suggested_params: algosdk.transaction.SuggestedParams ) -> algosdk.transaction.Transaction: - txn = algosdk.transaction.KeyregTxn( - sender=params.sender, - sp=suggested_params, - votekey=params.vote_key, - selkey=params.selection_key, - votefst=params.vote_first, - votelst=params.vote_last, - votekd=params.vote_key_dilution, - rekey_to=params.rekey_to, - nonpart=False, - sprfkey=params.state_proof_key, - ) + txn_params = { + "sender": params.sender, + "sp": suggested_params, + "votekey": params.vote_key, + "selkey": params.selection_key, + "votefst": params.vote_first, + "votelst": params.vote_last, + "votekd": params.vote_key_dilution, + "rekey_to": params.rekey_to, + "nonpart": False, + "sprfkey": params.state_proof_key, + } - return self._common_txn_build_step(params, txn, suggested_params) + return self._common_txn_build_step(lambda x: algosdk.transaction.KeyregTxn(**x), params, txn_params) def _is_abi_value(self, x: bool | float | str | bytes | list | TxnParams) -> bool: if isinstance(x, list | tuple): diff --git a/src/algokit_utils/transactions/transaction_sender.py b/src/algokit_utils/transactions/transaction_sender.py index 831100d2..31fa15a4 100644 --- a/src/algokit_utils/transactions/transaction_sender.py +++ b/src/algokit_utils/transactions/transaction_sender.py @@ -112,7 +112,11 @@ def send_transaction(params: T) -> SendSingleTransactionResult: transaction = composer.build().transactions[-1].txn logger.debug(pre_log(params, transaction)) - raw_result = composer.send() + raw_result = composer.send( + populate_app_call_resources=params.populate_app_call_resources, + max_rounds_to_wait=params.max_rounds_to_wait, + suppress_log=params.suppress_log, + ) raw_result_dict = raw_result.__dict__.copy() raw_result_dict["transactions"] = raw_result.transactions del raw_result_dict["simulate_response"] diff --git a/src/algokit_utils/transactions/utils.py b/src/algokit_utils/transactions/utils.py index 216db262..6ea09224 100644 --- a/src/algokit_utils/transactions/utils.py +++ b/src/algokit_utils/transactions/utils.py @@ -1,17 +1,58 @@ +import base64 +from copy import deepcopy from typing import Any, cast from algosdk import logic, transaction from algosdk.atomic_transaction_composer import AtomicTransactionComposer, EmptySigner, TransactionWithSigner -from algosdk.box_reference import BoxReference from algosdk.error import AtomicTransactionComposerError from algosdk.v2client.algod import AlgodClient from algosdk.v2client.models import SimulateRequest +from algokit_utils.applications.app_manager import BoxReference + # Constants MAX_APP_CALL_ACCOUNT_REFERENCES = 4 MAX_APP_CALL_FOREIGN_REFERENCES = 8 +def _find_available_transaction_index( + txns: list[TransactionWithSigner], reference_type: str, reference: str | dict[str, Any] | int +) -> int: + """Find index of first transaction that can accommodate the new reference.""" + + def check_transaction(txn: TransactionWithSigner) -> bool: + # Skip if not an application call transaction + if txn.txn.type != "appl": + return False + + # Get current counts (using get() with default 0 for Pythonic null handling) + accounts = len(getattr(txn.txn, "accounts", []) or []) + assets = len(getattr(txn.txn, "foreign_assets", []) or []) + apps = len(getattr(txn.txn, "foreign_apps", []) or []) + boxes = len(getattr(txn.txn, "boxes", []) or []) + + # For account references, only check account limit + if reference_type == "account": + return accounts < MAX_APP_CALL_ACCOUNT_REFERENCES + + # For asset holdings or local state, need space for both account and other reference + if reference_type in ("asset_holding", "app_local"): + return ( + accounts + assets + apps + boxes < MAX_APP_CALL_FOREIGN_REFERENCES - 1 + and accounts < MAX_APP_CALL_ACCOUNT_REFERENCES + ) + + # For boxes with non-zero app ID, need space for box and app reference + if reference_type == "box" and reference and int(getattr(reference, "app", 0)) != 0: + return accounts + assets + apps + boxes < MAX_APP_CALL_FOREIGN_REFERENCES - 1 + + # Default case - just check total references + return accounts + assets + apps + boxes < MAX_APP_CALL_FOREIGN_REFERENCES + + # Return first matching index or -1 if none found + return next((i for i, txn in enumerate(txns) if check_transaction(txn)), -1) + + def populate_app_call_resources(atc: AtomicTransactionComposer, algod: AlgodClient) -> AtomicTransactionComposer: # noqa: C901, PLR0915, PLR0912 """ Populate application call resources based on simulation results. @@ -26,7 +67,7 @@ def populate_app_call_resources(atc: AtomicTransactionComposer, algod: AlgodClie continue # Validate no unexpected resources - if txn_resources.get("boxes") or txn_resources.get("extraBoxRefs"): + if txn_resources.get("boxes") or txn_resources.get("extra-box-refs"): raise ValueError("Unexpected boxes at the transaction level") if txn_resources.get("appLocals"): raise ValueError("Unexpected app local at the transaction level") @@ -64,29 +105,28 @@ def populate_app_call_resources(atc: AtomicTransactionComposer, algod: AlgodClie app_txn.foreign_assets = foreign_assets app_txn.boxes = boxes - def populate_group_resource( # noqa: C901, PLR0915 - txns: list[TransactionWithSigner], reference: str | BoxReference | dict[str, Any] | int, ref_type: str + def populate_group_resource( # noqa: C901, PLR0912, PLR0915 + txns: list[TransactionWithSigner], reference: str | dict[str, Any] | int, ref_type: str ) -> None: - """Helper function to populate group-level resources""" + """Helper function to populate group-level resources matching TypeScript implementation""" def is_appl_below_limit(t: TransactionWithSigner) -> bool: if not isinstance(t.txn, transaction.ApplicationCallTxn): return False - app_txn = t.txn - accounts = len(app_txn.accounts or []) - assets = len(app_txn.foreign_assets or []) - apps = len(app_txn.foreign_apps or []) - boxes = len(app_txn.boxes or []) + accounts = len(getattr(t.txn, "accounts", []) or []) + assets = len(getattr(t.txn, "foreign_assets", []) or []) + apps = len(getattr(t.txn, "foreign_apps", []) or []) + boxes = len(getattr(t.txn, "boxes", []) or []) return accounts + assets + apps + boxes < MAX_APP_CALL_FOREIGN_REFERENCES - # Handle asset holding and app local references + # Handle asset holding and app local references first if ref_type in ("assetHolding", "appLocal"): ref_dict = cast(dict[str, Any], reference) account = ref_dict["account"] - # Try to find transaction with account already available + # First try to find transaction with account already available txn_idx = next( ( i @@ -100,7 +140,7 @@ def is_appl_below_limit(t: TransactionWithSigner) -> bool: logic.get_application_address(app_id) for app_id in (getattr(t.txn, "foreign_apps", []) or []) ) - or any(account in str(v) for v in t.txn.__dict__.values()) + or any(str(account) in str(v) for v in t.txn.__dict__.values()) ) ), -1, @@ -116,48 +156,90 @@ def is_appl_below_limit(t: TransactionWithSigner) -> bool: app_txn.foreign_apps = [*list(getattr(app_txn, "foreign_apps", []) or []), app_id] return + # Try to find transaction that already has the app/asset available + txn_idx = next( + ( + i + for i, t in enumerate(txns) + if is_appl_below_limit(t) + and isinstance(t.txn, transaction.ApplicationCallTxn) + and len(getattr(t.txn, "accounts", []) or []) < MAX_APP_CALL_ACCOUNT_REFERENCES + and ( + ( + ref_type == "assetHolding" + and ref_dict["asset"] in (getattr(t.txn, "foreign_assets", []) or []) + ) + or ( + ref_type == "appLocal" + and ( + ref_dict["app"] in (getattr(t.txn, "foreign_apps", []) or []) + or t.txn.index == ref_dict["app"] + ) + ) + ) + ), + -1, + ) + + if txn_idx >= 0: + app_txn = cast(transaction.ApplicationCallTxn, txns[txn_idx].txn) + accounts = list(getattr(app_txn, "accounts", []) or []) + accounts.append(account) + app_txn.accounts = accounts + return + + # Handle box references + if ref_type == "box": + box_ref: tuple[int, bytes] = (reference["app"], base64.b64decode(reference["name"])) # type: ignore # noqa: PGH003 + + # Try to find transaction that already has the app available + txn_idx = next( + ( + i + for i, t in enumerate(txns) + if is_appl_below_limit(t) + and isinstance(t.txn, transaction.ApplicationCallTxn) + and (box_ref[0] in (getattr(t.txn, "foreign_apps", []) or []) or t.txn.index == box_ref[0]) + ), + -1, + ) + + if txn_idx >= 0: + app_txn = cast(transaction.ApplicationCallTxn, txns[txn_idx].txn) + boxes = list(getattr(app_txn, "boxes", []) or []) + boxes.append(BoxReference.translate_box_reference(box_ref, app_txn.foreign_apps or [], app_txn.index)) + app_txn.boxes = boxes + return + # Find available transaction for the resource - txn_idx = next( - ( - i - for i, t in enumerate(txns) - if is_appl_below_limit(t) - and isinstance(t.txn, transaction.ApplicationCallTxn) - and ( - len(getattr(t.txn, "accounts", []) or []) < MAX_APP_CALL_ACCOUNT_REFERENCES - if ref_type == "account" - else True - ) - ), - -1, - ) + txn_idx = _find_available_transaction_index(txns, ref_type, reference) if txn_idx == -1: raise ValueError("No more transactions below reference limit. Add another app call to the group.") app_txn = cast(transaction.ApplicationCallTxn, txns[txn_idx].txn) - # Add resource based on type if ref_type == "account": accounts = list(getattr(app_txn, "accounts", []) or []) accounts.append(cast(str, reference)) app_txn.accounts = accounts elif ref_type == "app": + app_id = int(cast(str | int, reference)) foreign_apps = list(getattr(app_txn, "foreign_apps", []) or []) - foreign_apps.append(int(cast(str | int, reference))) + foreign_apps.append(app_id) app_txn.foreign_apps = foreign_apps elif ref_type == "box": - box_ref = cast(BoxReference, reference) boxes = list(getattr(app_txn, "boxes", []) or []) - boxes.append(box_ref) + boxes.append(BoxReference.translate_box_reference(box_ref, app_txn.foreign_apps or [], app_txn.index)) app_txn.boxes = boxes - if box_ref.app_index != 0: + if box_ref[0] != 0: foreign_apps = list(getattr(app_txn, "foreign_apps", []) or []) - foreign_apps.append(box_ref.app_index) + foreign_apps.append(box_ref[0]) app_txn.foreign_apps = foreign_apps elif ref_type == "asset": + asset_id = int(cast(str | int, reference)) foreign_assets = list(getattr(app_txn, "foreign_assets", []) or []) - foreign_assets.append(int(cast(str | int, reference))) + foreign_assets.append(asset_id) app_txn.foreign_assets = foreign_assets elif ref_type == "assetHolding": ref_dict = cast(dict[str, Any], reference) @@ -218,10 +300,9 @@ def is_appl_below_limit(t: TransactionWithSigner) -> bool: populate_group_resource(group, app, "app") # Handle extra box references - extra_box_refs = group_resources.get("extraBoxRefs", 0) + extra_box_refs = group_resources.get("extra-box-refs", 0) for _ in range(extra_box_refs): - empty_box = BoxReference(0, b"") - populate_group_resource(group, empty_box, "box") + populate_group_resource(group, {"app": 0, "name": ""}, "box") # Create new ATC with updated transactions new_atc = AtomicTransactionComposer() @@ -230,7 +311,7 @@ def is_appl_below_limit(t: TransactionWithSigner) -> bool: new_atc.add_transaction(txn_with_signer) # Copy method calls - new_atc.method_dict = atc.method_dict.copy() + new_atc.method_dict = deepcopy(atc.method_dict) return new_atc diff --git a/tests/artifacts/resource-packer/.gitignore b/tests/artifacts/resource-packer/.gitignore new file mode 100644 index 00000000..edd15a59 --- /dev/null +++ b/tests/artifacts/resource-packer/.gitignore @@ -0,0 +1,3 @@ +*.teal +*.json +!*.arc32.json diff --git a/tests/artifacts/resource-packer/ExternalApp.arc32.json b/tests/artifacts/resource-packer/ExternalApp.arc32.json new file mode 100644 index 00000000..88424a70 --- /dev/null +++ b/tests/artifacts/resource-packer/ExternalApp.arc32.json @@ -0,0 +1,140 @@ +{ + "hints": { + "optInToApplication()void": { + "call_config": { + "opt_in": "CALL" + } + }, + "dummy()void": { + "call_config": { + "no_op": "CALL" + } + }, + "error()void": { + "call_config": { + "no_op": "CALL" + } + }, + "boxWithPayment(pay)void": { + "call_config": { + "no_op": "CALL" + } + }, + "createAsset()void": { + "call_config": { + "no_op": "CALL" + } + }, + "senderAssetBalance()void": { + "call_config": { + "no_op": "CALL" + } + }, + "createApplication()void": { + "call_config": { + "no_op": "CREATE" + } + } + }, + "bare_call_config": { + "no_op": "NEVER", + "opt_in": "NEVER", + "close_out": "NEVER", + "update_application": "NEVER", + "delete_application": "NEVER" + }, + "schema": { + "local": { + "declared": { + "localKey": { + "type": "bytes", + "key": "localKey" + } + }, + "reserved": {} + }, + "global": { + "declared": { + "asa": { + "type": "uint64", + "key": "asa" + } + }, + "reserved": {} + } + }, + "state": { + "global": { + "num_byte_slices": 0, + "num_uints": 1 + }, + "local": { + "num_byte_slices": 1, + "num_uints": 0 + } + }, + "source": { + "approval": "I3ByYWdtYSB2ZXJzaW9uIDEwCgovLyBUaGlzIFRFQUwgd2FzIGdlbmVyYXRlZCBieSBURUFMU2NyaXB0IHYwLjg3LjAKLy8gaHR0cHM6Ly9naXRodWIuY29tL2FsZ29yYW5kZm91bmRhdGlvbi9URUFMU2NyaXB0CgovLyBUaGlzIGNvbnRyYWN0IGlzIGNvbXBsaWFudCB3aXRoIGFuZC9vciBpbXBsZW1lbnRzIHRoZSBmb2xsb3dpbmcgQVJDczogWyBBUkM0IF0KCi8vIFRoZSBmb2xsb3dpbmcgdGVuIGxpbmVzIG9mIFRFQUwgaGFuZGxlIGluaXRpYWwgcHJvZ3JhbSBmbG93Ci8vIFRoaXMgcGF0dGVybiBpcyB1c2VkIHRvIG1ha2UgaXQgZWFzeSBmb3IgYW55b25lIHRvIHBhcnNlIHRoZSBzdGFydCBvZiB0aGUgcHJvZ3JhbSBhbmQgZGV0ZXJtaW5lIGlmIGEgc3BlY2lmaWMgYWN0aW9uIGlzIGFsbG93ZWQKLy8gSGVyZSwgYWN0aW9uIHJlZmVycyB0byB0aGUgT25Db21wbGV0ZSBpbiBjb21iaW5hdGlvbiB3aXRoIHdoZXRoZXIgdGhlIGFwcCBpcyBiZWluZyBjcmVhdGVkIG9yIGNhbGxlZAovLyBFdmVyeSBwb3NzaWJsZSBhY3Rpb24gZm9yIHRoaXMgY29udHJhY3QgaXMgcmVwcmVzZW50ZWQgaW4gdGhlIHN3aXRjaCBzdGF0ZW1lbnQKLy8gSWYgdGhlIGFjdGlvbiBpcyBub3QgaW1wbGVtZW50ZWQgaW4gdGhlIGNvbnRyYWN0LCBpdHMgcmVzcGVjdGl2ZSBicmFuY2ggd2lsbCBiZSAiKk5PVF9JTVBMRU1FTlRFRCIgd2hpY2gganVzdCBjb250YWlucyAiZXJyIgp0eG4gQXBwbGljYXRpb25JRAohCmludCA2CioKdHhuIE9uQ29tcGxldGlvbgorCnN3aXRjaCAqY2FsbF9Ob09wICpjYWxsX09wdEluICpOT1RfSU1QTEVNRU5URUQgKk5PVF9JTVBMRU1FTlRFRCAqTk9UX0lNUExFTUVOVEVEICpOT1RfSU1QTEVNRU5URUQgKmNyZWF0ZV9Ob09wICpOT1RfSU1QTEVNRU5URUQgKk5PVF9JTVBMRU1FTlRFRCAqTk9UX0lNUExFTUVOVEVEICpOT1RfSU1QTEVNRU5URUQgKk5PVF9JTVBMRU1FTlRFRAoKKk5PVF9JTVBMRU1FTlRFRDoKCWVycgoKLy8gb3B0SW5Ub0FwcGxpY2F0aW9uKCl2b2lkCiphYmlfcm91dGVfb3B0SW5Ub0FwcGxpY2F0aW9uOgoJLy8gZXhlY3V0ZSBvcHRJblRvQXBwbGljYXRpb24oKXZvaWQKCWNhbGxzdWIgb3B0SW5Ub0FwcGxpY2F0aW9uCglpbnQgMQoJcmV0dXJuCgovLyBvcHRJblRvQXBwbGljYXRpb24oKTogdm9pZApvcHRJblRvQXBwbGljYXRpb246Cglwcm90byAwIDAKCgkvLyB0ZXN0cy9leGFtcGxlLWNvbnRyYWN0cy9yZXNvdXJjZS1wYWNrZXIvcmVzb3VyY2UtcGFja2VyLmFsZ28udHM6MTIKCS8vIHRoaXMubG9jYWxLZXkodGhpcy50eG4uc2VuZGVyKS52YWx1ZSA9ICdmb28nCgl0eG4gU2VuZGVyCglieXRlIDB4NmM2ZjYzNjE2YzRiNjU3OSAvLyAibG9jYWxLZXkiCglieXRlIDB4NjY2ZjZmIC8vICJmb28iCglhcHBfbG9jYWxfcHV0CglyZXRzdWIKCi8vIGR1bW15KCl2b2lkCiphYmlfcm91dGVfZHVtbXk6CgkvLyBleGVjdXRlIGR1bW15KCl2b2lkCgljYWxsc3ViIGR1bW15CglpbnQgMQoJcmV0dXJuCgovLyBkdW1teSgpOiB2b2lkCmR1bW15OgoJcHJvdG8gMCAwCglyZXRzdWIKCi8vIGVycm9yKCl2b2lkCiphYmlfcm91dGVfZXJyb3I6CgkvLyBleGVjdXRlIGVycm9yKCl2b2lkCgljYWxsc3ViIGVycm9yCglpbnQgMQoJcmV0dXJuCgovLyBlcnJvcigpOiB2b2lkCmVycm9yOgoJcHJvdG8gMCAwCgllcnIKCi8vIGJveFdpdGhQYXltZW50KHBheSl2b2lkCiphYmlfcm91dGVfYm94V2l0aFBheW1lbnQ6CgkvLyBfcGF5bWVudDogcGF5Cgl0eG4gR3JvdXBJbmRleAoJaW50IDEKCS0KCWR1cAoJZ3R4bnMgVHlwZUVudW0KCWludCBwYXkKCT09Cglhc3NlcnQKCgkvLyBleGVjdXRlIGJveFdpdGhQYXltZW50KHBheSl2b2lkCgljYWxsc3ViIGJveFdpdGhQYXltZW50CglpbnQgMQoJcmV0dXJuCgovLyBib3hXaXRoUGF5bWVudChfcGF5bWVudDogUGF5VHhuKTogdm9pZApib3hXaXRoUGF5bWVudDoKCXByb3RvIDEgMAoKCS8vIHRlc3RzL2V4YW1wbGUtY29udHJhY3RzL3Jlc291cmNlLXBhY2tlci9yZXNvdXJjZS1wYWNrZXIuYWxnby50czoyMgoJLy8gdGhpcy5ib3hLZXkudmFsdWUgPSAnZm9vJwoJYnl0ZSAweDYyNmY3ODRiNjU3OSAvLyAiYm94S2V5IgoJZHVwCglib3hfZGVsCglwb3AKCWJ5dGUgMHg2NjZmNmYgLy8gImZvbyIKCWJveF9wdXQKCXJldHN1YgoKLy8gY3JlYXRlQXNzZXQoKXZvaWQKKmFiaV9yb3V0ZV9jcmVhdGVBc3NldDoKCS8vIGV4ZWN1dGUgY3JlYXRlQXNzZXQoKXZvaWQKCWNhbGxzdWIgY3JlYXRlQXNzZXQKCWludCAxCglyZXR1cm4KCi8vIGNyZWF0ZUFzc2V0KCk6IHZvaWQKY3JlYXRlQXNzZXQ6Cglwcm90byAwIDAKCgkvLyB0ZXN0cy9leGFtcGxlLWNvbnRyYWN0cy9yZXNvdXJjZS1wYWNrZXIvcmVzb3VyY2UtcGFja2VyLmFsZ28udHM6MjYKCS8vIHRoaXMuYXNhLnZhbHVlID0gc2VuZEFzc2V0Q3JlYXRpb24oewoJLy8gICAgICAgY29uZmlnQXNzZXRUb3RhbDogMSwKCS8vICAgICB9KQoJYnl0ZSAweDYxNzM2MSAvLyAiYXNhIgoJaXR4bl9iZWdpbgoJaW50IGFjZmcKCWl0eG5fZmllbGQgVHlwZUVudW0KCgkvLyB0ZXN0cy9leGFtcGxlLWNvbnRyYWN0cy9yZXNvdXJjZS1wYWNrZXIvcmVzb3VyY2UtcGFja2VyLmFsZ28udHM6MjcKCS8vIGNvbmZpZ0Fzc2V0VG90YWw6IDEKCWludCAxCglpdHhuX2ZpZWxkIENvbmZpZ0Fzc2V0VG90YWwKCgkvLyBGZWUgZmllbGQgbm90IHNldCwgZGVmYXVsdGluZyB0byAwCglpbnQgMAoJaXR4bl9maWVsZCBGZWUKCgkvLyBTdWJtaXQgaW5uZXIgdHJhbnNhY3Rpb24KCWl0eG5fc3VibWl0CglpdHhuIENyZWF0ZWRBc3NldElECglhcHBfZ2xvYmFsX3B1dAoJcmV0c3ViCgovLyBzZW5kZXJBc3NldEJhbGFuY2UoKXZvaWQKKmFiaV9yb3V0ZV9zZW5kZXJBc3NldEJhbGFuY2U6CgkvLyBleGVjdXRlIHNlbmRlckFzc2V0QmFsYW5jZSgpdm9pZAoJY2FsbHN1YiBzZW5kZXJBc3NldEJhbGFuY2UKCWludCAxCglyZXR1cm4KCi8vIHNlbmRlckFzc2V0QmFsYW5jZSgpOiB2b2lkCnNlbmRlckFzc2V0QmFsYW5jZToKCXByb3RvIDAgMAoKCS8vIHRlc3RzL2V4YW1wbGUtY29udHJhY3RzL3Jlc291cmNlLXBhY2tlci9yZXNvdXJjZS1wYWNrZXIuYWxnby50czozMgoJLy8gYXNzZXJ0KCF0aGlzLnR4bi5zZW5kZXIuaXNPcHRlZEluVG9Bc3NldCh0aGlzLmFzYS52YWx1ZSkpCgl0eG4gU2VuZGVyCglieXRlIDB4NjE3MzYxIC8vICJhc2EiCglhcHBfZ2xvYmFsX2dldAoJYXNzZXRfaG9sZGluZ19nZXQgQXNzZXRCYWxhbmNlCglzd2FwCglwb3AKCSEKCWFzc2VydAoJcmV0c3ViCgoqYWJpX3JvdXRlX2NyZWF0ZUFwcGxpY2F0aW9uOgoJaW50IDEKCXJldHVybgoKKmNyZWF0ZV9Ob09wOgoJbWV0aG9kICJjcmVhdGVBcHBsaWNhdGlvbigpdm9pZCIKCXR4bmEgQXBwbGljYXRpb25BcmdzIDAKCW1hdGNoICphYmlfcm91dGVfY3JlYXRlQXBwbGljYXRpb24KCWVycgoKKmNhbGxfTm9PcDoKCW1ldGhvZCAiZHVtbXkoKXZvaWQiCgltZXRob2QgImVycm9yKCl2b2lkIgoJbWV0aG9kICJib3hXaXRoUGF5bWVudChwYXkpdm9pZCIKCW1ldGhvZCAiY3JlYXRlQXNzZXQoKXZvaWQiCgltZXRob2QgInNlbmRlckFzc2V0QmFsYW5jZSgpdm9pZCIKCXR4bmEgQXBwbGljYXRpb25BcmdzIDAKCW1hdGNoICphYmlfcm91dGVfZHVtbXkgKmFiaV9yb3V0ZV9lcnJvciAqYWJpX3JvdXRlX2JveFdpdGhQYXltZW50ICphYmlfcm91dGVfY3JlYXRlQXNzZXQgKmFiaV9yb3V0ZV9zZW5kZXJBc3NldEJhbGFuY2UKCWVycgoKKmNhbGxfT3B0SW46CgltZXRob2QgIm9wdEluVG9BcHBsaWNhdGlvbigpdm9pZCIKCXR4bmEgQXBwbGljYXRpb25BcmdzIDAKCW1hdGNoICphYmlfcm91dGVfb3B0SW5Ub0FwcGxpY2F0aW9uCgllcnI=", + "clear": "I3ByYWdtYSB2ZXJzaW9uIDEw" + }, + "contract": { + "name": "ExternalApp", + "desc": "", + "methods": [ + { + "name": "optInToApplication", + "args": [], + "returns": { + "type": "void" + } + }, + { + "name": "dummy", + "args": [], + "returns": { + "type": "void" + } + }, + { + "name": "error", + "args": [], + "returns": { + "type": "void" + } + }, + { + "name": "boxWithPayment", + "args": [ + { + "name": "_payment", + "type": "pay" + } + ], + "returns": { + "type": "void" + } + }, + { + "name": "createAsset", + "args": [], + "returns": { + "type": "void" + } + }, + { + "name": "senderAssetBalance", + "args": [], + "returns": { + "type": "void" + } + }, + { + "name": "createApplication", + "args": [], + "returns": { + "type": "void" + } + } + ] + } +} \ No newline at end of file diff --git a/tests/artifacts/resource-packer/ExternalAppV8.arc32.json b/tests/artifacts/resource-packer/ExternalAppV8.arc32.json new file mode 100644 index 00000000..d8f07b6b --- /dev/null +++ b/tests/artifacts/resource-packer/ExternalAppV8.arc32.json @@ -0,0 +1,69 @@ +{ + "hints": { + "dummy()void": { + "call_config": { + "no_op": "CALL" + } + }, + "createApplication()void": { + "call_config": { + "no_op": "CREATE" + } + } + }, + "bare_call_config": { + "no_op": "NEVER", + "opt_in": "NEVER", + "close_out": "NEVER", + "update_application": "NEVER", + "delete_application": "NEVER" + }, + "schema": { + "local": { + "declared": {}, + "reserved": {} + }, + "global": { + "declared": {}, + "reserved": {} + } + }, + "state": { + "global": { + "num_byte_slices": 0, + "num_uints": 0 + }, + "local": { + "num_byte_slices": 0, + "num_uints": 0 + } + }, + "source": { + "approval": "I3ByYWdtYSB2ZXJzaW9uIDkKCi8vIFRoaXMgVEVBTCB3YXMgZ2VuZXJhdGVkIGJ5IFRFQUxTY3JpcHQgdjAuNjMuMAovLyBodHRwczovL2dpdGh1Yi5jb20vYWxnb3JhbmRmb3VuZGF0aW9uL1RFQUxTY3JpcHQKCi8vIFRoaXMgY29udHJhY3QgaXMgY29tcGxpYW50IHdpdGggYW5kL29yIGltcGxlbWVudHMgdGhlIGZvbGxvd2luZyBBUkNzOiBbIEFSQzQgXQoKLy8gVGhlIGZvbGxvd2luZyB0ZW4gbGluZXMgb2YgVEVBTCBoYW5kbGUgaW5pdGlhbCBwcm9ncmFtIGZsb3cKLy8gVGhpcyBwYXR0ZXJuIGlzIHVzZWQgdG8gbWFrZSBpdCBlYXN5IGZvciBhbnlvbmUgdG8gcGFyc2UgdGhlIHN0YXJ0IG9mIHRoZSBwcm9ncmFtIGFuZCBkZXRlcm1pbmUgaWYgYSBzcGVjaWZpYyBhY3Rpb24gaXMgYWxsb3dlZAovLyBIZXJlLCBhY3Rpb24gcmVmZXJzIHRvIHRoZSBPbkNvbXBsZXRlIGluIGNvbWJpbmF0aW9uIHdpdGggd2hldGhlciB0aGUgYXBwIGlzIGJlaW5nIGNyZWF0ZWQgb3IgY2FsbGVkCi8vIEV2ZXJ5IHBvc3NpYmxlIGFjdGlvbiBmb3IgdGhpcyBjb250cmFjdCBpcyByZXByZXNlbnRlZCBpbiB0aGUgc3dpdGNoIHN0YXRlbWVudAovLyBJZiB0aGUgYWN0aW9uIGlzIG5vdCBpbXBsbWVudGVkIGluIHRoZSBjb250cmFjdCwgaXRzIHJlcHNlY3RpdmUgYnJhbmNoIHdpbGwgYmUgIk5PVF9JTVBMTUVOVEVEIiB3aGljaCBqdXN0IGNvbnRhaW5zICJlcnIiCnR4biBBcHBsaWNhdGlvbklECmludCAwCj4KaW50IDYKKgp0eG4gT25Db21wbGV0aW9uCisKc3dpdGNoIGNyZWF0ZV9Ob09wIE5PVF9JTVBMRU1FTlRFRCBOT1RfSU1QTEVNRU5URUQgTk9UX0lNUExFTUVOVEVEIE5PVF9JTVBMRU1FTlRFRCBOT1RfSU1QTEVNRU5URUQgY2FsbF9Ob09wCgpOT1RfSU1QTEVNRU5URUQ6CgllcnIKCi8vIGR1bW15KCl2b2lkCmFiaV9yb3V0ZV9kdW1teToKCS8vIGV4ZWN1dGUgZHVtbXkoKXZvaWQKCWNhbGxzdWIgZHVtbXkKCWludCAxCglyZXR1cm4KCmR1bW15OgoJcHJvdG8gMCAwCglyZXRzdWIKCmFiaV9yb3V0ZV9jcmVhdGVBcHBsaWNhdGlvbjoKCWludCAxCglyZXR1cm4KCmNyZWF0ZV9Ob09wOgoJbWV0aG9kICJjcmVhdGVBcHBsaWNhdGlvbigpdm9pZCIKCXR4bmEgQXBwbGljYXRpb25BcmdzIDAKCW1hdGNoIGFiaV9yb3V0ZV9jcmVhdGVBcHBsaWNhdGlvbgoJZXJyCgpjYWxsX05vT3A6CgltZXRob2QgImR1bW15KCl2b2lkIgoJdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMAoJbWF0Y2ggYWJpX3JvdXRlX2R1bW15CgllcnI=", + "clear": "I3ByYWdtYSB2ZXJzaW9uIDk=" + }, + "contract": { + "name": "ExternalAppV8", + "desc": "", + "methods": [ + { + "name": "dummy", + "args": [], + "desc": "", + "returns": { + "type": "void", + "desc": "" + } + }, + { + "name": "createApplication", + "desc": "", + "returns": { + "type": "void", + "desc": "" + }, + "args": [] + } + ] + } +} \ No newline at end of file diff --git a/tests/artifacts/resource-packer/ResourcePackerv8.arc32.json b/tests/artifacts/resource-packer/ResourcePackerv8.arc32.json new file mode 100644 index 00000000..d5afe92f --- /dev/null +++ b/tests/artifacts/resource-packer/ResourcePackerv8.arc32.json @@ -0,0 +1,173 @@ +{ + "hints": { + "bootstrap()void": { + "call_config": { + "no_op": "CALL" + } + }, + "addressBalance(address)void": { + "call_config": { + "no_op": "CALL" + } + }, + "smallBox()void": { + "call_config": { + "no_op": "CALL" + } + }, + "mediumBox()void": { + "call_config": { + "no_op": "CALL" + } + }, + "externalAppCall()void": { + "call_config": { + "no_op": "CALL" + } + }, + "assetTotal()void": { + "call_config": { + "no_op": "CALL" + } + }, + "hasAsset(address)void": { + "call_config": { + "no_op": "CALL" + } + }, + "externalLocal(address)void": { + "call_config": { + "no_op": "CALL" + } + }, + "createApplication()void": { + "call_config": { + "no_op": "CREATE" + } + } + }, + "bare_call_config": { + "no_op": "NEVER", + "opt_in": "NEVER", + "close_out": "NEVER", + "update_application": "NEVER", + "delete_application": "NEVER" + }, + "schema": { + "local": { + "declared": {}, + "reserved": {} + }, + "global": { + "declared": { + "externalAppID": { + "type": "uint64", + "key": "externalAppID" + }, + "asa": { + "type": "uint64", + "key": "asa" + } + }, + "reserved": {} + } + }, + "state": { + "global": { + "num_byte_slices": 0, + "num_uints": 2 + }, + "local": { + "num_byte_slices": 0, + "num_uints": 0 + } + }, + "source": { + "approval": "I3ByYWdtYSB2ZXJzaW9uIDgKCi8vIFRoaXMgVEVBTCB3YXMgZ2VuZXJhdGVkIGJ5IFRFQUxTY3JpcHQgdjAuODcuMAovLyBodHRwczovL2dpdGh1Yi5jb20vYWxnb3JhbmRmb3VuZGF0aW9uL1RFQUxTY3JpcHQKCi8vIFRoaXMgY29udHJhY3QgaXMgY29tcGxpYW50IHdpdGggYW5kL29yIGltcGxlbWVudHMgdGhlIGZvbGxvd2luZyBBUkNzOiBbIEFSQzQgXQoKLy8gVGhlIGZvbGxvd2luZyB0ZW4gbGluZXMgb2YgVEVBTCBoYW5kbGUgaW5pdGlhbCBwcm9ncmFtIGZsb3cKLy8gVGhpcyBwYXR0ZXJuIGlzIHVzZWQgdG8gbWFrZSBpdCBlYXN5IGZvciBhbnlvbmUgdG8gcGFyc2UgdGhlIHN0YXJ0IG9mIHRoZSBwcm9ncmFtIGFuZCBkZXRlcm1pbmUgaWYgYSBzcGVjaWZpYyBhY3Rpb24gaXMgYWxsb3dlZAovLyBIZXJlLCBhY3Rpb24gcmVmZXJzIHRvIHRoZSBPbkNvbXBsZXRlIGluIGNvbWJpbmF0aW9uIHdpdGggd2hldGhlciB0aGUgYXBwIGlzIGJlaW5nIGNyZWF0ZWQgb3IgY2FsbGVkCi8vIEV2ZXJ5IHBvc3NpYmxlIGFjdGlvbiBmb3IgdGhpcyBjb250cmFjdCBpcyByZXByZXNlbnRlZCBpbiB0aGUgc3dpdGNoIHN0YXRlbWVudAovLyBJZiB0aGUgYWN0aW9uIGlzIG5vdCBpbXBsZW1lbnRlZCBpbiB0aGUgY29udHJhY3QsIGl0cyByZXNwZWN0aXZlIGJyYW5jaCB3aWxsIGJlICIqTk9UX0lNUExFTUVOVEVEIiB3aGljaCBqdXN0IGNvbnRhaW5zICJlcnIiCnR4biBBcHBsaWNhdGlvbklECiEKaW50IDYKKgp0eG4gT25Db21wbGV0aW9uCisKc3dpdGNoICpjYWxsX05vT3AgKk5PVF9JTVBMRU1FTlRFRCAqTk9UX0lNUExFTUVOVEVEICpOT1RfSU1QTEVNRU5URUQgKk5PVF9JTVBMRU1FTlRFRCAqTk9UX0lNUExFTUVOVEVEICpjcmVhdGVfTm9PcCAqTk9UX0lNUExFTUVOVEVEICpOT1RfSU1QTEVNRU5URUQgKk5PVF9JTVBMRU1FTlRFRCAqTk9UX0lNUExFTUVOVEVEICpOT1RfSU1QTEVNRU5URUQKCipOT1RfSU1QTEVNRU5URUQ6CgllcnIKCi8vIGJvb3RzdHJhcCgpdm9pZAoqYWJpX3JvdXRlX2Jvb3RzdHJhcDoKCS8vIGV4ZWN1dGUgYm9vdHN0cmFwKCl2b2lkCgljYWxsc3ViIGJvb3RzdHJhcAoJaW50IDEKCXJldHVybgoKLy8gYm9vdHN0cmFwKCk6IHZvaWQKYm9vdHN0cmFwOgoJcHJvdG8gMCAwCgoJLy8gdGVzdHMvZXhhbXBsZS1jb250cmFjdHMvcmVzb3VyY2UtcGFja2VyL3Jlc291cmNlLXBhY2tlci5hbGdvLnRzOjQ5CgkvLyBzZW5kTWV0aG9kQ2FsbDxbXSwgdm9pZD4oewoJLy8gICAgICAgbmFtZTogJ2NyZWF0ZUFwcGxpY2F0aW9uJywKCS8vICAgICAgIGFwcHJvdmFsUHJvZ3JhbTogRXh0ZXJuYWxBcHAuYXBwcm92YWxQcm9ncmFtKCksCgkvLyAgICAgICBjbGVhclN0YXRlUHJvZ3JhbTogRXh0ZXJuYWxBcHAuY2xlYXJQcm9ncmFtKCksCgkvLyAgICAgICBsb2NhbE51bUJ5dGVTbGljZTogRXh0ZXJuYWxBcHAuc2NoZW1hLmxvY2FsLm51bUJ5dGVTbGljZSwKCS8vICAgICAgIGdsb2JhbE51bUJ5dGVTbGljZTogRXh0ZXJuYWxBcHAuc2NoZW1hLmdsb2JhbC5udW1CeXRlU2xpY2UsCgkvLyAgICAgICBnbG9iYWxOdW1VaW50OiBFeHRlcm5hbEFwcC5zY2hlbWEuZ2xvYmFsLm51bVVpbnQsCgkvLyAgICAgICBsb2NhbE51bVVpbnQ6IEV4dGVybmFsQXBwLnNjaGVtYS5sb2NhbC5udW1VaW50LAoJLy8gICAgIH0pCglpdHhuX2JlZ2luCglpbnQgYXBwbAoJaXR4bl9maWVsZCBUeXBlRW51bQoJbWV0aG9kICJjcmVhdGVBcHBsaWNhdGlvbigpdm9pZCIKCWl0eG5fZmllbGQgQXBwbGljYXRpb25BcmdzCgoJLy8gdGVzdHMvZXhhbXBsZS1jb250cmFjdHMvcmVzb3VyY2UtcGFja2VyL3Jlc291cmNlLXBhY2tlci5hbGdvLnRzOjUxCgkvLyBhcHByb3ZhbFByb2dyYW06IEV4dGVybmFsQXBwLmFwcHJvdmFsUHJvZ3JhbSgpCglieXRlIGI2NCBDaUFCQVNZQ0EyWnZid05oYzJFeEdCU0JCZ3N4R1FpTkRBQ0hBTFVBQUFBQUFBQUFBQUI1QUFBQUFBQUFBQUFBQUFDSUFBSWlRNG9BQURFQWdBaHNiMk5oYkV0bGVTaG1pWWdBQWlKRGlnQUFpWWdBQWlKRGlnQUFBREVXSWdsSk9CQWlFa1NJQUFJaVE0b0JBSUFHWW05NFMyVjVTYnhJS0wrSmlBQUNJa09LQUFBcHNZRURzaEFpc2lLQkFMSUJzN1E4WjRtSUFBSWlRNG9BQURFQUtXUndBRXhJRkVTSklrT0FCTGhFZXpZMkdnQ09BZi94QUlBRW93em4vNEFFUk5EYURZQUUxaDVDVllBRXBscXIvb0FFWlZ4ZUFqWWFBSTRGLzJUL2JmOTIvNWIvc0FDQUJBR2pvLzgyR2dDT0FmOC9BQT09CglpdHhuX2ZpZWxkIEFwcHJvdmFsUHJvZ3JhbQoKCS8vIHRlc3RzL2V4YW1wbGUtY29udHJhY3RzL3Jlc291cmNlLXBhY2tlci9yZXNvdXJjZS1wYWNrZXIuYWxnby50czo1MgoJLy8gY2xlYXJTdGF0ZVByb2dyYW06IEV4dGVybmFsQXBwLmNsZWFyUHJvZ3JhbSgpCglieXRlIGI2NCBDZz09CglpdHhuX2ZpZWxkIENsZWFyU3RhdGVQcm9ncmFtCgoJLy8gdGVzdHMvZXhhbXBsZS1jb250cmFjdHMvcmVzb3VyY2UtcGFja2VyL3Jlc291cmNlLXBhY2tlci5hbGdvLnRzOjUzCgkvLyBsb2NhbE51bUJ5dGVTbGljZTogRXh0ZXJuYWxBcHAuc2NoZW1hLmxvY2FsLm51bUJ5dGVTbGljZQoJaW50IDEKCWl0eG5fZmllbGQgTG9jYWxOdW1CeXRlU2xpY2UKCgkvLyB0ZXN0cy9leGFtcGxlLWNvbnRyYWN0cy9yZXNvdXJjZS1wYWNrZXIvcmVzb3VyY2UtcGFja2VyLmFsZ28udHM6NTQKCS8vIGdsb2JhbE51bUJ5dGVTbGljZTogRXh0ZXJuYWxBcHAuc2NoZW1hLmdsb2JhbC5udW1CeXRlU2xpY2UKCWludCAwCglpdHhuX2ZpZWxkIEdsb2JhbE51bUJ5dGVTbGljZQoKCS8vIHRlc3RzL2V4YW1wbGUtY29udHJhY3RzL3Jlc291cmNlLXBhY2tlci9yZXNvdXJjZS1wYWNrZXIuYWxnby50czo1NQoJLy8gZ2xvYmFsTnVtVWludDogRXh0ZXJuYWxBcHAuc2NoZW1hLmdsb2JhbC5udW1VaW50CglpbnQgMQoJaXR4bl9maWVsZCBHbG9iYWxOdW1VaW50CgoJLy8gdGVzdHMvZXhhbXBsZS1jb250cmFjdHMvcmVzb3VyY2UtcGFja2VyL3Jlc291cmNlLXBhY2tlci5hbGdvLnRzOjU2CgkvLyBsb2NhbE51bVVpbnQ6IEV4dGVybmFsQXBwLnNjaGVtYS5sb2NhbC5udW1VaW50CglpbnQgMAoJaXR4bl9maWVsZCBMb2NhbE51bVVpbnQKCgkvLyBGZWUgZmllbGQgbm90IHNldCwgZGVmYXVsdGluZyB0byAwCglpbnQgMAoJaXR4bl9maWVsZCBGZWUKCgkvLyBTdWJtaXQgaW5uZXIgdHJhbnNhY3Rpb24KCWl0eG5fc3VibWl0CgoJLy8gdGVzdHMvZXhhbXBsZS1jb250cmFjdHMvcmVzb3VyY2UtcGFja2VyL3Jlc291cmNlLXBhY2tlci5hbGdvLnRzOjU5CgkvLyB0aGlzLmV4dGVybmFsQXBwSUQudmFsdWUgPSB0aGlzLml0eG4uY3JlYXRlZEFwcGxpY2F0aW9uSUQKCWJ5dGUgMHg2NTc4NzQ2NTcyNmU2MTZjNDE3MDcwNDk0NCAvLyAiZXh0ZXJuYWxBcHBJRCIKCWl0eG4gQ3JlYXRlZEFwcGxpY2F0aW9uSUQKCWFwcF9nbG9iYWxfcHV0CgoJLy8gdGVzdHMvZXhhbXBsZS1jb250cmFjdHMvcmVzb3VyY2UtcGFja2VyL3Jlc291cmNlLXBhY2tlci5hbGdvLnRzOjYxCgkvLyB0aGlzLmFzYS52YWx1ZSA9IHNlbmRBc3NldENyZWF0aW9uKHsKCS8vICAgICAgIGNvbmZpZ0Fzc2V0VG90YWw6IDEsCgkvLyAgICAgfSkKCWJ5dGUgMHg2MTczNjEgLy8gImFzYSIKCWl0eG5fYmVnaW4KCWludCBhY2ZnCglpdHhuX2ZpZWxkIFR5cGVFbnVtCgoJLy8gdGVzdHMvZXhhbXBsZS1jb250cmFjdHMvcmVzb3VyY2UtcGFja2VyL3Jlc291cmNlLXBhY2tlci5hbGdvLnRzOjYyCgkvLyBjb25maWdBc3NldFRvdGFsOiAxCglpbnQgMQoJaXR4bl9maWVsZCBDb25maWdBc3NldFRvdGFsCgoJLy8gRmVlIGZpZWxkIG5vdCBzZXQsIGRlZmF1bHRpbmcgdG8gMAoJaW50IDAKCWl0eG5fZmllbGQgRmVlCgoJLy8gU3VibWl0IGlubmVyIHRyYW5zYWN0aW9uCglpdHhuX3N1Ym1pdAoJaXR4biBDcmVhdGVkQXNzZXRJRAoJYXBwX2dsb2JhbF9wdXQKCXJldHN1YgoKLy8gYWRkcmVzc0JhbGFuY2UoYWRkcmVzcyl2b2lkCiphYmlfcm91dGVfYWRkcmVzc0JhbGFuY2U6CgkvLyBhZGRyOiBhZGRyZXNzCgl0eG5hIEFwcGxpY2F0aW9uQXJncyAxCglkdXAKCWxlbgoJaW50IDMyCgk9PQoJYXNzZXJ0CgoJLy8gZXhlY3V0ZSBhZGRyZXNzQmFsYW5jZShhZGRyZXNzKXZvaWQKCWNhbGxzdWIgYWRkcmVzc0JhbGFuY2UKCWludCAxCglyZXR1cm4KCi8vIGFkZHJlc3NCYWxhbmNlKGFkZHI6IEFkZHJlc3MpOiB2b2lkCmFkZHJlc3NCYWxhbmNlOgoJcHJvdG8gMSAwCgoJLy8gdGVzdHMvZXhhbXBsZS1jb250cmFjdHMvcmVzb3VyY2UtcGFja2VyL3Jlc291cmNlLXBhY2tlci5hbGdvLnRzOjY3CgkvLyBsb2cocmF3Qnl0ZXMoYWRkci5pc0luTGVkZ2VyKSkKCWZyYW1lX2RpZyAtMSAvLyBhZGRyOiBBZGRyZXNzCglhY2N0X3BhcmFtc19nZXQgQWNjdEJhbGFuY2UKCXN3YXAKCXBvcAoJYnl0ZSAweDAwCglpbnQgMAoJdW5jb3ZlciAyCglzZXRiaXQKCWxvZwoJcmV0c3ViCgovLyBzbWFsbEJveCgpdm9pZAoqYWJpX3JvdXRlX3NtYWxsQm94OgoJLy8gZXhlY3V0ZSBzbWFsbEJveCgpdm9pZAoJY2FsbHN1YiBzbWFsbEJveAoJaW50IDEKCXJldHVybgoKLy8gc21hbGxCb3goKTogdm9pZApzbWFsbEJveDoKCXByb3RvIDAgMAoKCS8vIHRlc3RzL2V4YW1wbGUtY29udHJhY3RzL3Jlc291cmNlLXBhY2tlci9yZXNvdXJjZS1wYWNrZXIuYWxnby50czo3MQoJLy8gdGhpcy5zbWFsbEJveEtleS52YWx1ZSA9ICcnCglieXRlIDB4NzMgLy8gInMiCglkdXAKCWJveF9kZWwKCXBvcAoJYnl0ZSAweCAvLyAiIgoJYm94X3B1dAoJcmV0c3ViCgovLyBtZWRpdW1Cb3goKXZvaWQKKmFiaV9yb3V0ZV9tZWRpdW1Cb3g6CgkvLyBleGVjdXRlIG1lZGl1bUJveCgpdm9pZAoJY2FsbHN1YiBtZWRpdW1Cb3gKCWludCAxCglyZXR1cm4KCi8vIG1lZGl1bUJveCgpOiB2b2lkCm1lZGl1bUJveDoKCXByb3RvIDAgMAoKCS8vIHRlc3RzL2V4YW1wbGUtY29udHJhY3RzL3Jlc291cmNlLXBhY2tlci9yZXNvdXJjZS1wYWNrZXIuYWxnby50czo3NQoJLy8gdGhpcy5tZWRpdW1Cb3hLZXkuY3JlYXRlKDVfMDAwKQoJYnl0ZSAweDZkIC8vICJtIgoJaW50IDVfMDAwCglib3hfY3JlYXRlCglwb3AKCXJldHN1YgoKLy8gZXh0ZXJuYWxBcHBDYWxsKCl2b2lkCiphYmlfcm91dGVfZXh0ZXJuYWxBcHBDYWxsOgoJLy8gZXhlY3V0ZSBleHRlcm5hbEFwcENhbGwoKXZvaWQKCWNhbGxzdWIgZXh0ZXJuYWxBcHBDYWxsCglpbnQgMQoJcmV0dXJuCgovLyBleHRlcm5hbEFwcENhbGwoKTogdm9pZApleHRlcm5hbEFwcENhbGw6Cglwcm90byAwIDAKCgkvLyB0ZXN0cy9leGFtcGxlLWNvbnRyYWN0cy9yZXNvdXJjZS1wYWNrZXIvcmVzb3VyY2UtcGFja2VyLmFsZ28udHM6NzkKCS8vIHNlbmRNZXRob2RDYWxsPFtdLCB2b2lkPih7CgkvLyAgICAgICBhcHBsaWNhdGlvbklEOiB0aGlzLmV4dGVybmFsQXBwSUQudmFsdWUsCgkvLyAgICAgICBuYW1lOiAnZHVtbXknLAoJLy8gICAgIH0pCglpdHhuX2JlZ2luCglpbnQgYXBwbAoJaXR4bl9maWVsZCBUeXBlRW51bQoJbWV0aG9kICJkdW1teSgpdm9pZCIKCWl0eG5fZmllbGQgQXBwbGljYXRpb25BcmdzCgoJLy8gdGVzdHMvZXhhbXBsZS1jb250cmFjdHMvcmVzb3VyY2UtcGFja2VyL3Jlc291cmNlLXBhY2tlci5hbGdvLnRzOjgwCgkvLyBhcHBsaWNhdGlvbklEOiB0aGlzLmV4dGVybmFsQXBwSUQudmFsdWUKCWJ5dGUgMHg2NTc4NzQ2NTcyNmU2MTZjNDE3MDcwNDk0NCAvLyAiZXh0ZXJuYWxBcHBJRCIKCWFwcF9nbG9iYWxfZ2V0CglpdHhuX2ZpZWxkIEFwcGxpY2F0aW9uSUQKCgkvLyBGZWUgZmllbGQgbm90IHNldCwgZGVmYXVsdGluZyB0byAwCglpbnQgMAoJaXR4bl9maWVsZCBGZWUKCgkvLyBTdWJtaXQgaW5uZXIgdHJhbnNhY3Rpb24KCWl0eG5fc3VibWl0CglyZXRzdWIKCi8vIGFzc2V0VG90YWwoKXZvaWQKKmFiaV9yb3V0ZV9hc3NldFRvdGFsOgoJLy8gZXhlY3V0ZSBhc3NldFRvdGFsKCl2b2lkCgljYWxsc3ViIGFzc2V0VG90YWwKCWludCAxCglyZXR1cm4KCi8vIGFzc2V0VG90YWwoKTogdm9pZAphc3NldFRvdGFsOgoJcHJvdG8gMCAwCgoJLy8gdGVzdHMvZXhhbXBsZS1jb250cmFjdHMvcmVzb3VyY2UtcGFja2VyL3Jlc291cmNlLXBhY2tlci5hbGdvLnRzOjg2CgkvLyBhc3NlcnQodGhpcy5hc2EudmFsdWUudG90YWwpCglieXRlIDB4NjE3MzYxIC8vICJhc2EiCglhcHBfZ2xvYmFsX2dldAoJYXNzZXRfcGFyYW1zX2dldCBBc3NldFRvdGFsCglwb3AKCWFzc2VydAoJcmV0c3ViCgovLyBoYXNBc3NldChhZGRyZXNzKXZvaWQKKmFiaV9yb3V0ZV9oYXNBc3NldDoKCS8vIGFkZHI6IGFkZHJlc3MKCXR4bmEgQXBwbGljYXRpb25BcmdzIDEKCWR1cAoJbGVuCglpbnQgMzIKCT09Cglhc3NlcnQKCgkvLyBleGVjdXRlIGhhc0Fzc2V0KGFkZHJlc3Mpdm9pZAoJY2FsbHN1YiBoYXNBc3NldAoJaW50IDEKCXJldHVybgoKLy8gaGFzQXNzZXQoYWRkcjogQWRkcmVzcyk6IHZvaWQKaGFzQXNzZXQ6Cglwcm90byAxIDAKCgkvLyB0ZXN0cy9leGFtcGxlLWNvbnRyYWN0cy9yZXNvdXJjZS1wYWNrZXIvcmVzb3VyY2UtcGFja2VyLmFsZ28udHM6OTAKCS8vIGFzc2VydCghYWRkci5pc09wdGVkSW5Ub0Fzc2V0KHRoaXMuYXNhLnZhbHVlKSkKCWZyYW1lX2RpZyAtMSAvLyBhZGRyOiBBZGRyZXNzCglieXRlIDB4NjE3MzYxIC8vICJhc2EiCglhcHBfZ2xvYmFsX2dldAoJYXNzZXRfaG9sZGluZ19nZXQgQXNzZXRCYWxhbmNlCglzd2FwCglwb3AKCSEKCWFzc2VydAoJcmV0c3ViCgovLyBleHRlcm5hbExvY2FsKGFkZHJlc3Mpdm9pZAoqYWJpX3JvdXRlX2V4dGVybmFsTG9jYWw6CgkvLyBhZGRyOiBhZGRyZXNzCgl0eG5hIEFwcGxpY2F0aW9uQXJncyAxCglkdXAKCWxlbgoJaW50IDMyCgk9PQoJYXNzZXJ0CgoJLy8gZXhlY3V0ZSBleHRlcm5hbExvY2FsKGFkZHJlc3Mpdm9pZAoJY2FsbHN1YiBleHRlcm5hbExvY2FsCglpbnQgMQoJcmV0dXJuCgovLyBleHRlcm5hbExvY2FsKGFkZHI6IEFkZHJlc3MpOiB2b2lkCmV4dGVybmFsTG9jYWw6Cglwcm90byAxIDAKCgkvLyB0ZXN0cy9leGFtcGxlLWNvbnRyYWN0cy9yZXNvdXJjZS1wYWNrZXIvcmVzb3VyY2UtcGFja2VyLmFsZ28udHM6OTQKCS8vIGxvZyh0aGlzLmV4dGVybmFsQXBwSUQudmFsdWUubG9jYWxTdGF0ZShhZGRyLCAnbG9jYWxLZXknKSBhcyBieXRlcykKCWJ5dGUgMHg2NTc4NzQ2NTcyNmU2MTZjNDE3MDcwNDk0NCAvLyAiZXh0ZXJuYWxBcHBJRCIKCWFwcF9nbG9iYWxfZ2V0CglieXRlIDB4NmM2ZjYzNjE2YzRiNjU3OSAvLyAibG9jYWxLZXkiCglmcmFtZV9kaWcgLTEgLy8gYWRkcjogQWRkcmVzcwoJY292ZXIgMgoJYXBwX2xvY2FsX2dldF9leAoJYXNzZXJ0Cglsb2cKCXJldHN1YgoKKmFiaV9yb3V0ZV9jcmVhdGVBcHBsaWNhdGlvbjoKCWludCAxCglyZXR1cm4KCipjcmVhdGVfTm9PcDoKCW1ldGhvZCAiY3JlYXRlQXBwbGljYXRpb24oKXZvaWQiCgl0eG5hIEFwcGxpY2F0aW9uQXJncyAwCgltYXRjaCAqYWJpX3JvdXRlX2NyZWF0ZUFwcGxpY2F0aW9uCgllcnIKCipjYWxsX05vT3A6CgltZXRob2QgImJvb3RzdHJhcCgpdm9pZCIKCW1ldGhvZCAiYWRkcmVzc0JhbGFuY2UoYWRkcmVzcyl2b2lkIgoJbWV0aG9kICJzbWFsbEJveCgpdm9pZCIKCW1ldGhvZCAibWVkaXVtQm94KCl2b2lkIgoJbWV0aG9kICJleHRlcm5hbEFwcENhbGwoKXZvaWQiCgltZXRob2QgImFzc2V0VG90YWwoKXZvaWQiCgltZXRob2QgImhhc0Fzc2V0KGFkZHJlc3Mpdm9pZCIKCW1ldGhvZCAiZXh0ZXJuYWxMb2NhbChhZGRyZXNzKXZvaWQiCgl0eG5hIEFwcGxpY2F0aW9uQXJncyAwCgltYXRjaCAqYWJpX3JvdXRlX2Jvb3RzdHJhcCAqYWJpX3JvdXRlX2FkZHJlc3NCYWxhbmNlICphYmlfcm91dGVfc21hbGxCb3ggKmFiaV9yb3V0ZV9tZWRpdW1Cb3ggKmFiaV9yb3V0ZV9leHRlcm5hbEFwcENhbGwgKmFiaV9yb3V0ZV9hc3NldFRvdGFsICphYmlfcm91dGVfaGFzQXNzZXQgKmFiaV9yb3V0ZV9leHRlcm5hbExvY2FsCgllcnI=", + "clear": "I3ByYWdtYSB2ZXJzaW9uIDg=" + }, + "contract": { + "name": "ResourcePackerv8", + "desc": "", + "methods": [ + { + "name": "bootstrap", + "args": [], + "returns": { + "type": "void" + } + }, + { + "name": "addressBalance", + "args": [ + { + "name": "addr", + "type": "address" + } + ], + "returns": { + "type": "void" + } + }, + { + "name": "smallBox", + "args": [], + "returns": { + "type": "void" + } + }, + { + "name": "mediumBox", + "args": [], + "returns": { + "type": "void" + } + }, + { + "name": "externalAppCall", + "args": [], + "returns": { + "type": "void" + } + }, + { + "name": "assetTotal", + "args": [], + "returns": { + "type": "void" + } + }, + { + "name": "hasAsset", + "args": [ + { + "name": "addr", + "type": "address" + } + ], + "returns": { + "type": "void" + } + }, + { + "name": "externalLocal", + "args": [ + { + "name": "addr", + "type": "address" + } + ], + "returns": { + "type": "void" + } + }, + { + "name": "createApplication", + "args": [], + "returns": { + "type": "void" + } + } + ] + } +} \ No newline at end of file diff --git a/tests/artifacts/resource-packer/ResourcePackerv9.arc32.json b/tests/artifacts/resource-packer/ResourcePackerv9.arc32.json new file mode 100644 index 00000000..2aa29555 --- /dev/null +++ b/tests/artifacts/resource-packer/ResourcePackerv9.arc32.json @@ -0,0 +1,173 @@ +{ + "hints": { + "bootstrap()void": { + "call_config": { + "no_op": "CALL" + } + }, + "addressBalance(address)void": { + "call_config": { + "no_op": "CALL" + } + }, + "smallBox()void": { + "call_config": { + "no_op": "CALL" + } + }, + "mediumBox()void": { + "call_config": { + "no_op": "CALL" + } + }, + "externalAppCall()void": { + "call_config": { + "no_op": "CALL" + } + }, + "assetTotal()void": { + "call_config": { + "no_op": "CALL" + } + }, + "hasAsset(address)void": { + "call_config": { + "no_op": "CALL" + } + }, + "externalLocal(address)void": { + "call_config": { + "no_op": "CALL" + } + }, + "createApplication()void": { + "call_config": { + "no_op": "CREATE" + } + } + }, + "bare_call_config": { + "no_op": "NEVER", + "opt_in": "NEVER", + "close_out": "NEVER", + "update_application": "NEVER", + "delete_application": "NEVER" + }, + "schema": { + "local": { + "declared": {}, + "reserved": {} + }, + "global": { + "declared": { + "externalAppID": { + "type": "uint64", + "key": "externalAppID" + }, + "asa": { + "type": "uint64", + "key": "asa" + } + }, + "reserved": {} + } + }, + "state": { + "global": { + "num_byte_slices": 0, + "num_uints": 2 + }, + "local": { + "num_byte_slices": 0, + "num_uints": 0 + } + }, + "source": { + "approval": "I3ByYWdtYSB2ZXJzaW9uIDkKCi8vIFRoaXMgVEVBTCB3YXMgZ2VuZXJhdGVkIGJ5IFRFQUxTY3JpcHQgdjAuODcuMAovLyBodHRwczovL2dpdGh1Yi5jb20vYWxnb3JhbmRmb3VuZGF0aW9uL1RFQUxTY3JpcHQKCi8vIFRoaXMgY29udHJhY3QgaXMgY29tcGxpYW50IHdpdGggYW5kL29yIGltcGxlbWVudHMgdGhlIGZvbGxvd2luZyBBUkNzOiBbIEFSQzQgXQoKLy8gVGhlIGZvbGxvd2luZyB0ZW4gbGluZXMgb2YgVEVBTCBoYW5kbGUgaW5pdGlhbCBwcm9ncmFtIGZsb3cKLy8gVGhpcyBwYXR0ZXJuIGlzIHVzZWQgdG8gbWFrZSBpdCBlYXN5IGZvciBhbnlvbmUgdG8gcGFyc2UgdGhlIHN0YXJ0IG9mIHRoZSBwcm9ncmFtIGFuZCBkZXRlcm1pbmUgaWYgYSBzcGVjaWZpYyBhY3Rpb24gaXMgYWxsb3dlZAovLyBIZXJlLCBhY3Rpb24gcmVmZXJzIHRvIHRoZSBPbkNvbXBsZXRlIGluIGNvbWJpbmF0aW9uIHdpdGggd2hldGhlciB0aGUgYXBwIGlzIGJlaW5nIGNyZWF0ZWQgb3IgY2FsbGVkCi8vIEV2ZXJ5IHBvc3NpYmxlIGFjdGlvbiBmb3IgdGhpcyBjb250cmFjdCBpcyByZXByZXNlbnRlZCBpbiB0aGUgc3dpdGNoIHN0YXRlbWVudAovLyBJZiB0aGUgYWN0aW9uIGlzIG5vdCBpbXBsZW1lbnRlZCBpbiB0aGUgY29udHJhY3QsIGl0cyByZXNwZWN0aXZlIGJyYW5jaCB3aWxsIGJlICIqTk9UX0lNUExFTUVOVEVEIiB3aGljaCBqdXN0IGNvbnRhaW5zICJlcnIiCnR4biBBcHBsaWNhdGlvbklECiEKaW50IDYKKgp0eG4gT25Db21wbGV0aW9uCisKc3dpdGNoICpjYWxsX05vT3AgKk5PVF9JTVBMRU1FTlRFRCAqTk9UX0lNUExFTUVOVEVEICpOT1RfSU1QTEVNRU5URUQgKk5PVF9JTVBMRU1FTlRFRCAqTk9UX0lNUExFTUVOVEVEICpjcmVhdGVfTm9PcCAqTk9UX0lNUExFTUVOVEVEICpOT1RfSU1QTEVNRU5URUQgKk5PVF9JTVBMRU1FTlRFRCAqTk9UX0lNUExFTUVOVEVEICpOT1RfSU1QTEVNRU5URUQKCipOT1RfSU1QTEVNRU5URUQ6CgllcnIKCi8vIGJvb3RzdHJhcCgpdm9pZAoqYWJpX3JvdXRlX2Jvb3RzdHJhcDoKCS8vIGV4ZWN1dGUgYm9vdHN0cmFwKCl2b2lkCgljYWxsc3ViIGJvb3RzdHJhcAoJaW50IDEKCXJldHVybgoKLy8gYm9vdHN0cmFwKCk6IHZvaWQKYm9vdHN0cmFwOgoJcHJvdG8gMCAwCgoJLy8gdGVzdHMvZXhhbXBsZS1jb250cmFjdHMvcmVzb3VyY2UtcGFja2VyL3Jlc291cmNlLXBhY2tlci5hbGdvLnRzOjExMQoJLy8gc2VuZE1ldGhvZENhbGw8W10sIHZvaWQ+KHsKCS8vICAgICAgIG5hbWU6ICdjcmVhdGVBcHBsaWNhdGlvbicsCgkvLyAgICAgICBhcHByb3ZhbFByb2dyYW06IEV4dGVybmFsQXBwLmFwcHJvdmFsUHJvZ3JhbSgpLAoJLy8gICAgICAgY2xlYXJTdGF0ZVByb2dyYW06IEV4dGVybmFsQXBwLmNsZWFyUHJvZ3JhbSgpLAoJLy8gICAgICAgbG9jYWxOdW1CeXRlU2xpY2U6IEV4dGVybmFsQXBwLnNjaGVtYS5sb2NhbC5udW1CeXRlU2xpY2UsCgkvLyAgICAgICBnbG9iYWxOdW1CeXRlU2xpY2U6IEV4dGVybmFsQXBwLnNjaGVtYS5nbG9iYWwubnVtQnl0ZVNsaWNlLAoJLy8gICAgICAgZ2xvYmFsTnVtVWludDogRXh0ZXJuYWxBcHAuc2NoZW1hLmdsb2JhbC5udW1VaW50LAoJLy8gICAgICAgbG9jYWxOdW1VaW50OiBFeHRlcm5hbEFwcC5zY2hlbWEubG9jYWwubnVtVWludCwKCS8vICAgICB9KQoJaXR4bl9iZWdpbgoJaW50IGFwcGwKCWl0eG5fZmllbGQgVHlwZUVudW0KCW1ldGhvZCAiY3JlYXRlQXBwbGljYXRpb24oKXZvaWQiCglpdHhuX2ZpZWxkIEFwcGxpY2F0aW9uQXJncwoKCS8vIHRlc3RzL2V4YW1wbGUtY29udHJhY3RzL3Jlc291cmNlLXBhY2tlci9yZXNvdXJjZS1wYWNrZXIuYWxnby50czoxMTMKCS8vIGFwcHJvdmFsUHJvZ3JhbTogRXh0ZXJuYWxBcHAuYXBwcm92YWxQcm9ncmFtKCkKCWJ5dGUgYjY0IENpQUJBU1lDQTJadmJ3TmhjMkV4R0JTQkJnc3hHUWlOREFDSEFMVUFBQUFBQUFBQUFBQjVBQUFBQUFBQUFBQUFBQUNJQUFJaVE0b0FBREVBZ0Foc2IyTmhiRXRsZVNobWlZZ0FBaUpEaWdBQWlZZ0FBaUpEaWdBQUFERVdJZ2xKT0JBaUVrU0lBQUlpUTRvQkFJQUdZbTk0UzJWNVNieElLTCtKaUFBQ0lrT0tBQUFwc1lFRHNoQWlzaUtCQUxJQnM3UThaNG1JQUFJaVE0b0FBREVBS1dSd0FFeElGRVNKSWtPQUJMaEVlelkyR2dDT0FmL3hBSUFFb3d6bi80QUVSTkRhRFlBRTFoNUNWWUFFcGxxci9vQUVaVnhlQWpZYUFJNEYvMlQvYmY5Mi81Yi9zQUNBQkFHam8vODJHZ0NPQWY4L0FBPT0KCWl0eG5fZmllbGQgQXBwcm92YWxQcm9ncmFtCgoJLy8gdGVzdHMvZXhhbXBsZS1jb250cmFjdHMvcmVzb3VyY2UtcGFja2VyL3Jlc291cmNlLXBhY2tlci5hbGdvLnRzOjExNAoJLy8gY2xlYXJTdGF0ZVByb2dyYW06IEV4dGVybmFsQXBwLmNsZWFyUHJvZ3JhbSgpCglieXRlIGI2NCBDZz09CglpdHhuX2ZpZWxkIENsZWFyU3RhdGVQcm9ncmFtCgoJLy8gdGVzdHMvZXhhbXBsZS1jb250cmFjdHMvcmVzb3VyY2UtcGFja2VyL3Jlc291cmNlLXBhY2tlci5hbGdvLnRzOjExNQoJLy8gbG9jYWxOdW1CeXRlU2xpY2U6IEV4dGVybmFsQXBwLnNjaGVtYS5sb2NhbC5udW1CeXRlU2xpY2UKCWludCAxCglpdHhuX2ZpZWxkIExvY2FsTnVtQnl0ZVNsaWNlCgoJLy8gdGVzdHMvZXhhbXBsZS1jb250cmFjdHMvcmVzb3VyY2UtcGFja2VyL3Jlc291cmNlLXBhY2tlci5hbGdvLnRzOjExNgoJLy8gZ2xvYmFsTnVtQnl0ZVNsaWNlOiBFeHRlcm5hbEFwcC5zY2hlbWEuZ2xvYmFsLm51bUJ5dGVTbGljZQoJaW50IDAKCWl0eG5fZmllbGQgR2xvYmFsTnVtQnl0ZVNsaWNlCgoJLy8gdGVzdHMvZXhhbXBsZS1jb250cmFjdHMvcmVzb3VyY2UtcGFja2VyL3Jlc291cmNlLXBhY2tlci5hbGdvLnRzOjExNwoJLy8gZ2xvYmFsTnVtVWludDogRXh0ZXJuYWxBcHAuc2NoZW1hLmdsb2JhbC5udW1VaW50CglpbnQgMQoJaXR4bl9maWVsZCBHbG9iYWxOdW1VaW50CgoJLy8gdGVzdHMvZXhhbXBsZS1jb250cmFjdHMvcmVzb3VyY2UtcGFja2VyL3Jlc291cmNlLXBhY2tlci5hbGdvLnRzOjExOAoJLy8gbG9jYWxOdW1VaW50OiBFeHRlcm5hbEFwcC5zY2hlbWEubG9jYWwubnVtVWludAoJaW50IDAKCWl0eG5fZmllbGQgTG9jYWxOdW1VaW50CgoJLy8gRmVlIGZpZWxkIG5vdCBzZXQsIGRlZmF1bHRpbmcgdG8gMAoJaW50IDAKCWl0eG5fZmllbGQgRmVlCgoJLy8gU3VibWl0IGlubmVyIHRyYW5zYWN0aW9uCglpdHhuX3N1Ym1pdAoKCS8vIHRlc3RzL2V4YW1wbGUtY29udHJhY3RzL3Jlc291cmNlLXBhY2tlci9yZXNvdXJjZS1wYWNrZXIuYWxnby50czoxMjEKCS8vIHRoaXMuZXh0ZXJuYWxBcHBJRC52YWx1ZSA9IHRoaXMuaXR4bi5jcmVhdGVkQXBwbGljYXRpb25JRAoJYnl0ZSAweDY1Nzg3NDY1NzI2ZTYxNmM0MTcwNzA0OTQ0IC8vICJleHRlcm5hbEFwcElEIgoJaXR4biBDcmVhdGVkQXBwbGljYXRpb25JRAoJYXBwX2dsb2JhbF9wdXQKCgkvLyB0ZXN0cy9leGFtcGxlLWNvbnRyYWN0cy9yZXNvdXJjZS1wYWNrZXIvcmVzb3VyY2UtcGFja2VyLmFsZ28udHM6MTIzCgkvLyB0aGlzLmFzYS52YWx1ZSA9IHNlbmRBc3NldENyZWF0aW9uKHsKCS8vICAgICAgIGNvbmZpZ0Fzc2V0VG90YWw6IDEsCgkvLyAgICAgfSkKCWJ5dGUgMHg2MTczNjEgLy8gImFzYSIKCWl0eG5fYmVnaW4KCWludCBhY2ZnCglpdHhuX2ZpZWxkIFR5cGVFbnVtCgoJLy8gdGVzdHMvZXhhbXBsZS1jb250cmFjdHMvcmVzb3VyY2UtcGFja2VyL3Jlc291cmNlLXBhY2tlci5hbGdvLnRzOjEyNAoJLy8gY29uZmlnQXNzZXRUb3RhbDogMQoJaW50IDEKCWl0eG5fZmllbGQgQ29uZmlnQXNzZXRUb3RhbAoKCS8vIEZlZSBmaWVsZCBub3Qgc2V0LCBkZWZhdWx0aW5nIHRvIDAKCWludCAwCglpdHhuX2ZpZWxkIEZlZQoKCS8vIFN1Ym1pdCBpbm5lciB0cmFuc2FjdGlvbgoJaXR4bl9zdWJtaXQKCWl0eG4gQ3JlYXRlZEFzc2V0SUQKCWFwcF9nbG9iYWxfcHV0CglyZXRzdWIKCi8vIGFkZHJlc3NCYWxhbmNlKGFkZHJlc3Mpdm9pZAoqYWJpX3JvdXRlX2FkZHJlc3NCYWxhbmNlOgoJLy8gYWRkcjogYWRkcmVzcwoJdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMQoJZHVwCglsZW4KCWludCAzMgoJPT0KCWFzc2VydAoKCS8vIGV4ZWN1dGUgYWRkcmVzc0JhbGFuY2UoYWRkcmVzcyl2b2lkCgljYWxsc3ViIGFkZHJlc3NCYWxhbmNlCglpbnQgMQoJcmV0dXJuCgovLyBhZGRyZXNzQmFsYW5jZShhZGRyOiBBZGRyZXNzKTogdm9pZAphZGRyZXNzQmFsYW5jZToKCXByb3RvIDEgMAoKCS8vIHRlc3RzL2V4YW1wbGUtY29udHJhY3RzL3Jlc291cmNlLXBhY2tlci9yZXNvdXJjZS1wYWNrZXIuYWxnby50czoxMjkKCS8vIGxvZyhyYXdCeXRlcyhhZGRyLmlzSW5MZWRnZXIpKQoJZnJhbWVfZGlnIC0xIC8vIGFkZHI6IEFkZHJlc3MKCWFjY3RfcGFyYW1zX2dldCBBY2N0QmFsYW5jZQoJc3dhcAoJcG9wCglieXRlIDB4MDAKCWludCAwCgl1bmNvdmVyIDIKCXNldGJpdAoJbG9nCglyZXRzdWIKCi8vIHNtYWxsQm94KCl2b2lkCiphYmlfcm91dGVfc21hbGxCb3g6CgkvLyBleGVjdXRlIHNtYWxsQm94KCl2b2lkCgljYWxsc3ViIHNtYWxsQm94CglpbnQgMQoJcmV0dXJuCgovLyBzbWFsbEJveCgpOiB2b2lkCnNtYWxsQm94OgoJcHJvdG8gMCAwCgoJLy8gdGVzdHMvZXhhbXBsZS1jb250cmFjdHMvcmVzb3VyY2UtcGFja2VyL3Jlc291cmNlLXBhY2tlci5hbGdvLnRzOjEzMwoJLy8gdGhpcy5zbWFsbEJveEtleS52YWx1ZSA9ICcnCglieXRlIDB4NzMgLy8gInMiCglkdXAKCWJveF9kZWwKCXBvcAoJYnl0ZSAweCAvLyAiIgoJYm94X3B1dAoJcmV0c3ViCgovLyBtZWRpdW1Cb3goKXZvaWQKKmFiaV9yb3V0ZV9tZWRpdW1Cb3g6CgkvLyBleGVjdXRlIG1lZGl1bUJveCgpdm9pZAoJY2FsbHN1YiBtZWRpdW1Cb3gKCWludCAxCglyZXR1cm4KCi8vIG1lZGl1bUJveCgpOiB2b2lkCm1lZGl1bUJveDoKCXByb3RvIDAgMAoKCS8vIHRlc3RzL2V4YW1wbGUtY29udHJhY3RzL3Jlc291cmNlLXBhY2tlci9yZXNvdXJjZS1wYWNrZXIuYWxnby50czoxMzcKCS8vIHRoaXMubWVkaXVtQm94S2V5LmNyZWF0ZSg1XzAwMCkKCWJ5dGUgMHg2ZCAvLyAibSIKCWludCA1XzAwMAoJYm94X2NyZWF0ZQoJcG9wCglyZXRzdWIKCi8vIGV4dGVybmFsQXBwQ2FsbCgpdm9pZAoqYWJpX3JvdXRlX2V4dGVybmFsQXBwQ2FsbDoKCS8vIGV4ZWN1dGUgZXh0ZXJuYWxBcHBDYWxsKCl2b2lkCgljYWxsc3ViIGV4dGVybmFsQXBwQ2FsbAoJaW50IDEKCXJldHVybgoKLy8gZXh0ZXJuYWxBcHBDYWxsKCk6IHZvaWQKZXh0ZXJuYWxBcHBDYWxsOgoJcHJvdG8gMCAwCgoJLy8gdGVzdHMvZXhhbXBsZS1jb250cmFjdHMvcmVzb3VyY2UtcGFja2VyL3Jlc291cmNlLXBhY2tlci5hbGdvLnRzOjE0MQoJLy8gc2VuZE1ldGhvZENhbGw8W10sIHZvaWQ+KHsKCS8vICAgICAgIGFwcGxpY2F0aW9uSUQ6IHRoaXMuZXh0ZXJuYWxBcHBJRC52YWx1ZSwKCS8vICAgICAgIG5hbWU6ICdkdW1teScsCgkvLyAgICAgfSkKCWl0eG5fYmVnaW4KCWludCBhcHBsCglpdHhuX2ZpZWxkIFR5cGVFbnVtCgltZXRob2QgImR1bW15KCl2b2lkIgoJaXR4bl9maWVsZCBBcHBsaWNhdGlvbkFyZ3MKCgkvLyB0ZXN0cy9leGFtcGxlLWNvbnRyYWN0cy9yZXNvdXJjZS1wYWNrZXIvcmVzb3VyY2UtcGFja2VyLmFsZ28udHM6MTQyCgkvLyBhcHBsaWNhdGlvbklEOiB0aGlzLmV4dGVybmFsQXBwSUQudmFsdWUKCWJ5dGUgMHg2NTc4NzQ2NTcyNmU2MTZjNDE3MDcwNDk0NCAvLyAiZXh0ZXJuYWxBcHBJRCIKCWFwcF9nbG9iYWxfZ2V0CglpdHhuX2ZpZWxkIEFwcGxpY2F0aW9uSUQKCgkvLyBGZWUgZmllbGQgbm90IHNldCwgZGVmYXVsdGluZyB0byAwCglpbnQgMAoJaXR4bl9maWVsZCBGZWUKCgkvLyBTdWJtaXQgaW5uZXIgdHJhbnNhY3Rpb24KCWl0eG5fc3VibWl0CglyZXRzdWIKCi8vIGFzc2V0VG90YWwoKXZvaWQKKmFiaV9yb3V0ZV9hc3NldFRvdGFsOgoJLy8gZXhlY3V0ZSBhc3NldFRvdGFsKCl2b2lkCgljYWxsc3ViIGFzc2V0VG90YWwKCWludCAxCglyZXR1cm4KCi8vIGFzc2V0VG90YWwoKTogdm9pZAphc3NldFRvdGFsOgoJcHJvdG8gMCAwCgoJLy8gdGVzdHMvZXhhbXBsZS1jb250cmFjdHMvcmVzb3VyY2UtcGFja2VyL3Jlc291cmNlLXBhY2tlci5hbGdvLnRzOjE0OAoJLy8gYXNzZXJ0KHRoaXMuYXNhLnZhbHVlLnRvdGFsKQoJYnl0ZSAweDYxNzM2MSAvLyAiYXNhIgoJYXBwX2dsb2JhbF9nZXQKCWFzc2V0X3BhcmFtc19nZXQgQXNzZXRUb3RhbAoJcG9wCglhc3NlcnQKCXJldHN1YgoKLy8gaGFzQXNzZXQoYWRkcmVzcyl2b2lkCiphYmlfcm91dGVfaGFzQXNzZXQ6CgkvLyBhZGRyOiBhZGRyZXNzCgl0eG5hIEFwcGxpY2F0aW9uQXJncyAxCglkdXAKCWxlbgoJaW50IDMyCgk9PQoJYXNzZXJ0CgoJLy8gZXhlY3V0ZSBoYXNBc3NldChhZGRyZXNzKXZvaWQKCWNhbGxzdWIgaGFzQXNzZXQKCWludCAxCglyZXR1cm4KCi8vIGhhc0Fzc2V0KGFkZHI6IEFkZHJlc3MpOiB2b2lkCmhhc0Fzc2V0OgoJcHJvdG8gMSAwCgoJLy8gdGVzdHMvZXhhbXBsZS1jb250cmFjdHMvcmVzb3VyY2UtcGFja2VyL3Jlc291cmNlLXBhY2tlci5hbGdvLnRzOjE1MgoJLy8gYXNzZXJ0KCFhZGRyLmlzT3B0ZWRJblRvQXNzZXQodGhpcy5hc2EudmFsdWUpKQoJZnJhbWVfZGlnIC0xIC8vIGFkZHI6IEFkZHJlc3MKCWJ5dGUgMHg2MTczNjEgLy8gImFzYSIKCWFwcF9nbG9iYWxfZ2V0Cglhc3NldF9ob2xkaW5nX2dldCBBc3NldEJhbGFuY2UKCXN3YXAKCXBvcAoJIQoJYXNzZXJ0CglyZXRzdWIKCi8vIGV4dGVybmFsTG9jYWwoYWRkcmVzcyl2b2lkCiphYmlfcm91dGVfZXh0ZXJuYWxMb2NhbDoKCS8vIGFkZHI6IGFkZHJlc3MKCXR4bmEgQXBwbGljYXRpb25BcmdzIDEKCWR1cAoJbGVuCglpbnQgMzIKCT09Cglhc3NlcnQKCgkvLyBleGVjdXRlIGV4dGVybmFsTG9jYWwoYWRkcmVzcyl2b2lkCgljYWxsc3ViIGV4dGVybmFsTG9jYWwKCWludCAxCglyZXR1cm4KCi8vIGV4dGVybmFsTG9jYWwoYWRkcjogQWRkcmVzcyk6IHZvaWQKZXh0ZXJuYWxMb2NhbDoKCXByb3RvIDEgMAoKCS8vIHRlc3RzL2V4YW1wbGUtY29udHJhY3RzL3Jlc291cmNlLXBhY2tlci9yZXNvdXJjZS1wYWNrZXIuYWxnby50czoxNTYKCS8vIGxvZyh0aGlzLmV4dGVybmFsQXBwSUQudmFsdWUubG9jYWxTdGF0ZShhZGRyLCAnbG9jYWxLZXknKSBhcyBieXRlcykKCWJ5dGUgMHg2NTc4NzQ2NTcyNmU2MTZjNDE3MDcwNDk0NCAvLyAiZXh0ZXJuYWxBcHBJRCIKCWFwcF9nbG9iYWxfZ2V0CglieXRlIDB4NmM2ZjYzNjE2YzRiNjU3OSAvLyAibG9jYWxLZXkiCglmcmFtZV9kaWcgLTEgLy8gYWRkcjogQWRkcmVzcwoJY292ZXIgMgoJYXBwX2xvY2FsX2dldF9leAoJYXNzZXJ0Cglsb2cKCXJldHN1YgoKKmFiaV9yb3V0ZV9jcmVhdGVBcHBsaWNhdGlvbjoKCWludCAxCglyZXR1cm4KCipjcmVhdGVfTm9PcDoKCW1ldGhvZCAiY3JlYXRlQXBwbGljYXRpb24oKXZvaWQiCgl0eG5hIEFwcGxpY2F0aW9uQXJncyAwCgltYXRjaCAqYWJpX3JvdXRlX2NyZWF0ZUFwcGxpY2F0aW9uCgllcnIKCipjYWxsX05vT3A6CgltZXRob2QgImJvb3RzdHJhcCgpdm9pZCIKCW1ldGhvZCAiYWRkcmVzc0JhbGFuY2UoYWRkcmVzcyl2b2lkIgoJbWV0aG9kICJzbWFsbEJveCgpdm9pZCIKCW1ldGhvZCAibWVkaXVtQm94KCl2b2lkIgoJbWV0aG9kICJleHRlcm5hbEFwcENhbGwoKXZvaWQiCgltZXRob2QgImFzc2V0VG90YWwoKXZvaWQiCgltZXRob2QgImhhc0Fzc2V0KGFkZHJlc3Mpdm9pZCIKCW1ldGhvZCAiZXh0ZXJuYWxMb2NhbChhZGRyZXNzKXZvaWQiCgl0eG5hIEFwcGxpY2F0aW9uQXJncyAwCgltYXRjaCAqYWJpX3JvdXRlX2Jvb3RzdHJhcCAqYWJpX3JvdXRlX2FkZHJlc3NCYWxhbmNlICphYmlfcm91dGVfc21hbGxCb3ggKmFiaV9yb3V0ZV9tZWRpdW1Cb3ggKmFiaV9yb3V0ZV9leHRlcm5hbEFwcENhbGwgKmFiaV9yb3V0ZV9hc3NldFRvdGFsICphYmlfcm91dGVfaGFzQXNzZXQgKmFiaV9yb3V0ZV9leHRlcm5hbExvY2FsCgllcnI=", + "clear": "I3ByYWdtYSB2ZXJzaW9uIDk=" + }, + "contract": { + "name": "ResourcePackerv9", + "desc": "", + "methods": [ + { + "name": "bootstrap", + "args": [], + "returns": { + "type": "void" + } + }, + { + "name": "addressBalance", + "args": [ + { + "name": "addr", + "type": "address" + } + ], + "returns": { + "type": "void" + } + }, + { + "name": "smallBox", + "args": [], + "returns": { + "type": "void" + } + }, + { + "name": "mediumBox", + "args": [], + "returns": { + "type": "void" + } + }, + { + "name": "externalAppCall", + "args": [], + "returns": { + "type": "void" + } + }, + { + "name": "assetTotal", + "args": [], + "returns": { + "type": "void" + } + }, + { + "name": "hasAsset", + "args": [ + { + "name": "addr", + "type": "address" + } + ], + "returns": { + "type": "void" + } + }, + { + "name": "externalLocal", + "args": [ + { + "name": "addr", + "type": "address" + } + ], + "returns": { + "type": "void" + } + }, + { + "name": "createApplication", + "args": [], + "returns": { + "type": "void" + } + } + ] + } +} \ No newline at end of file diff --git a/tests/artifacts/resource-packer/resource-packer.algo.ts b/tests/artifacts/resource-packer/resource-packer.algo.ts new file mode 100644 index 00000000..d1e98660 --- /dev/null +++ b/tests/artifacts/resource-packer/resource-packer.algo.ts @@ -0,0 +1,158 @@ +/* eslint-disable @typescript-eslint/no-empty-function */ +import { Contract } from '@algorandfoundation/tealscript' + +class ExternalApp extends Contract { + localKey = LocalStateKey() + + boxKey = BoxKey() + + asa = GlobalStateKey() + + optInToApplication(): void { + this.localKey(this.txn.sender).value = 'foo' + } + + dummy(): void {} + + error(): void { + throw Error() + } + + boxWithPayment(_payment: PayTxn): void { + this.boxKey.value = 'foo' + } + + createAsset(): void { + this.asa.value = sendAssetCreation({ + configAssetTotal: 1, + }) + } + + senderAssetBalance(): void { + assert(!this.txn.sender.isOptedInToAsset(this.asa.value)) + } +} + +// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars +class ResourcePackerv8 extends Contract { + programVersion = 8 + + externalAppID = GlobalStateKey() + + asa = GlobalStateKey() + + smallBoxKey = BoxKey({ key: 's' }) + + mediumBoxKey = BoxKey({ key: 'm' }) + + bootstrap(): void { + sendMethodCall<[], void>({ + name: 'createApplication', + approvalProgram: ExternalApp.approvalProgram(), + clearStateProgram: ExternalApp.clearProgram(), + localNumByteSlice: ExternalApp.schema.local.numByteSlice, + globalNumByteSlice: ExternalApp.schema.global.numByteSlice, + globalNumUint: ExternalApp.schema.global.numUint, + localNumUint: ExternalApp.schema.local.numUint, + }) + + this.externalAppID.value = this.itxn.createdApplicationID + + this.asa.value = sendAssetCreation({ + configAssetTotal: 1, + }) + } + + addressBalance(addr: Address): void { + log(rawBytes(addr.isInLedger)) + } + + smallBox(): void { + this.smallBoxKey.value = '' + } + + mediumBox(): void { + this.mediumBoxKey.create(5_000) + } + + externalAppCall(): void { + sendMethodCall<[], void>({ + applicationID: this.externalAppID.value, + name: 'dummy', + }) + } + + assetTotal(): void { + assert(this.asa.value.total) + } + + hasAsset(addr: Address): void { + assert(!addr.isOptedInToAsset(this.asa.value)) + } + + externalLocal(addr: Address): void { + log(this.externalAppID.value.localState(addr, 'localKey') as bytes) + } +} + +// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars +class ResourcePackerv9 extends Contract { + programVersion = 9 + + externalAppID = GlobalStateKey() + + asa = GlobalStateKey() + + smallBoxKey = BoxKey({ key: 's' }) + + mediumBoxKey = BoxKey({ key: 'm' }) + + bootstrap(): void { + sendMethodCall<[], void>({ + name: 'createApplication', + approvalProgram: ExternalApp.approvalProgram(), + clearStateProgram: ExternalApp.clearProgram(), + localNumByteSlice: ExternalApp.schema.local.numByteSlice, + globalNumByteSlice: ExternalApp.schema.global.numByteSlice, + globalNumUint: ExternalApp.schema.global.numUint, + localNumUint: ExternalApp.schema.local.numUint, + }) + + this.externalAppID.value = this.itxn.createdApplicationID + + this.asa.value = sendAssetCreation({ + configAssetTotal: 1, + }) + } + + addressBalance(addr: Address): void { + log(rawBytes(addr.isInLedger)) + } + + smallBox(): void { + this.smallBoxKey.value = '' + } + + mediumBox(): void { + this.mediumBoxKey.create(5_000) + } + + externalAppCall(): void { + sendMethodCall<[], void>({ + applicationID: this.externalAppID.value, + name: 'dummy', + }) + } + + assetTotal(): void { + assert(this.asa.value.total) + } + + hasAsset(addr: Address): void { + assert(!addr.isOptedInToAsset(this.asa.value)) + } + + externalLocal(addr: Address): void { + log(this.externalAppID.value.localState(addr, 'localKey') as bytes) + } +} diff --git a/tests/test_transaction_composer.py b/tests/test_transaction_composer.py deleted file mode 100644 index a7096c83..00000000 --- a/tests/test_transaction_composer.py +++ /dev/null @@ -1,219 +0,0 @@ -from typing import TYPE_CHECKING - -import pytest -from algosdk.transaction import ( - ApplicationCreateTxn, - AssetConfigTxn, - AssetCreateTxn, - PaymentTxn, -) - -from algokit_utils._legacy_v2.account import get_account -from algokit_utils.clients.algorand_client import AlgorandClient -from algokit_utils.models.account import Account -from algokit_utils.models.amount import AlgoAmount -from algokit_utils.transactions.transaction_composer import ( - AppCreateParams, - AssetConfigParams, - AssetCreateParams, - PaymentParams, - SendAtomicTransactionComposerResults, - TransactionComposer, -) -from legacy_v2_tests.conftest import get_unique_name - -if TYPE_CHECKING: - from algokit_utils.transactions.models import Arc2TransactionNote - - -@pytest.fixture -def algorand() -> AlgorandClient: - return AlgorandClient.default_local_net() - - -@pytest.fixture -def funded_account(algorand: AlgorandClient) -> Account: - new_account = algorand.account.random() - dispenser = algorand.account.localnet_dispenser() - algorand.account.ensure_funded( - new_account, dispenser, AlgoAmount.from_algos(100), min_funding_increment=AlgoAmount.from_algos(1) - ) - algorand.set_signer(sender=new_account.address, signer=new_account.signer) - return new_account - - -@pytest.fixture -def funded_secondary_account(algorand: AlgorandClient) -> Account: - secondary_name = get_unique_name() - return get_account(algorand.client.algod, secondary_name) - - -def test_add_transaction(algorand: AlgorandClient, funded_account: Account) -> None: - composer = TransactionComposer( - algod=algorand.client.algod, - get_signer=lambda _: funded_account.signer, - ) - txn = PaymentTxn( - sender=funded_account.address, - sp=algorand.client.algod.suggested_params(), - receiver=funded_account.address, - amt=AlgoAmount.from_algos(1).micro_algos, - ) - composer.add_transaction(txn) - built = composer.build_transactions() - - assert len(built.transactions) == 1 - assert isinstance(built.transactions[0], PaymentTxn) - assert built.transactions[0].sender == funded_account.address - assert built.transactions[0].receiver == funded_account.address - assert built.transactions[0].amt == AlgoAmount.from_algos(1).micro_algos - - -def test_add_asset_create(algorand: AlgorandClient, funded_account: Account) -> None: - composer = TransactionComposer( - algod=algorand.client.algod, - get_signer=lambda _: funded_account.signer, - ) - expected_total = 1000 - params = AssetCreateParams( - sender=funded_account.address, - total=expected_total, - decimals=0, - default_frozen=False, - unit_name="TEST", - asset_name="Test Asset", - url="https://example.com", - ) - - composer.add_asset_create(params) - built = composer.build_transactions() - response = composer.execute(max_rounds_to_wait=20) - created_asset = algorand.client.algod.asset_info( - algorand.client.algod.pending_transaction_info(response.tx_ids[0])["asset-index"] # type: ignore[call-overload] - )["params"] - - assert len(response.tx_ids) == 1 - assert response.confirmations[-1]["confirmed-round"] > 0 # type: ignore[call-overload] - assert isinstance(built.transactions[0], AssetCreateTxn) - txn = built.transactions[0] - assert txn.sender == funded_account.address - assert created_asset["creator"] == funded_account.address - assert txn.total == created_asset["total"] == expected_total - assert txn.decimals == created_asset["decimals"] == 0 - assert txn.default_frozen == created_asset["default-frozen"] is False - assert txn.unit_name == created_asset["unit-name"] == "TEST" - assert txn.asset_name == created_asset["name"] == "Test Asset" - - -def test_add_asset_config(algorand: AlgorandClient, funded_account: Account, funded_secondary_account: Account) -> None: - # First create an asset - asset_txn = AssetCreateTxn( - sender=funded_account.address, - sp=algorand.client.algod.suggested_params(), - total=1000, - decimals=0, - default_frozen=False, - unit_name="CFG", - asset_name="Configurable Asset", - manager=funded_account.address, - ) - signed_asset_txn = asset_txn.sign(funded_account.signer.private_key) - tx_id = algorand.client.algod.send_transaction(signed_asset_txn) - asset_before_config = algorand.client.algod.asset_info( - algorand.client.algod.pending_transaction_info(tx_id)["asset-index"] # type: ignore[call-overload] - ) - asset_before_config_index = asset_before_config["index"] # type: ignore[call-overload] - - composer = TransactionComposer( - algod=algorand.client.algod, - get_signer=lambda _: funded_account.signer, - ) - params = AssetConfigParams( - sender=funded_account.address, - asset_id=asset_before_config_index, - manager=funded_secondary_account.address, - ) - composer.add_asset_config(params) - built = composer.build_transactions() - - assert len(built.transactions) == 1 - assert isinstance(built.transactions[0], AssetConfigTxn) - txn = built.transactions[0] - assert txn.sender == funded_account.address - assert txn.index == asset_before_config_index - assert txn.manager == funded_secondary_account.address - - composer.execute(max_rounds_to_wait=20) - updated_asset = algorand.client.algod.asset_info(asset_id=asset_before_config_index)["params"] # type: ignore[call-overload] - assert updated_asset["manager"] == funded_secondary_account.address - - -def test_add_app_create(algorand: AlgorandClient, funded_account: Account) -> None: - composer = TransactionComposer( - algod=algorand.client.algod, - get_signer=lambda _: funded_account.signer, - ) - approval_program = "#pragma version 6\nint 1" - clear_state_program = "#pragma version 6\nint 1" - params = AppCreateParams( - sender=funded_account.address, - approval_program=approval_program, - clear_state_program=clear_state_program, - schema={"global_ints": 0, "global_bytes": 0, "local_ints": 0, "local_bytes": 0}, - ) - composer.add_app_create(params) - built = composer.build_transactions() - - assert len(built.transactions) == 1 - assert isinstance(built.transactions[0], ApplicationCreateTxn) - txn = built.transactions[0] - assert txn.sender == funded_account.address - assert txn.approval_program == b"\x06\x81\x01" - assert txn.clear_program == b"\x06\x81\x01" - composer.execute(max_rounds_to_wait=20) - - -def test_simulate(algorand: AlgorandClient, funded_account: Account) -> None: - composer = TransactionComposer( - algod=algorand.client.algod, - get_signer=lambda _: funded_account.signer, - ) - composer.add_payment( - PaymentParams( - sender=funded_account.address, - receiver=funded_account.address, - amount=AlgoAmount.from_algos(1), - ) - ) - composer.build() - simulate_response = composer.simulate() - assert simulate_response - - -def test_send(algorand: AlgorandClient, funded_account: Account) -> None: - composer = TransactionComposer( - algod=algorand.client.algod, - get_signer=lambda _: funded_account.signer, - ) - composer.add_payment( - PaymentParams( - sender=funded_account.address, - receiver=funded_account.address, - amount=AlgoAmount.from_algos(1), - ) - ) - response = composer.send() - assert isinstance(response, SendAtomicTransactionComposerResults) - assert len(response.tx_ids) == 1 - assert response.confirmations[-1]["confirmed-round"] > 0 # type: ignore[call-overload] - - -def test_arc2_note() -> None: - note_data: Arc2TransactionNote = { - "dapp_name": "TestDApp", - "format": "j", - "data": '{"key":"value"}', - } - encoded_note = TransactionComposer.arc2_note(note_data) - expected_note = b'TestDApp:j{"key":"value"}' - assert encoded_note == expected_note diff --git a/tests/transactions/test_resource_packing.py b/tests/transactions/test_resource_packing.py new file mode 100644 index 00000000..ea975470 --- /dev/null +++ b/tests/transactions/test_resource_packing.py @@ -0,0 +1,168 @@ +from collections.abc import Generator +from pathlib import Path + +import pytest + +from algokit_utils import Account +from algokit_utils.applications.app_client import ( + AppClientMethodCallWithSendParams, + FundAppAccountParams, +) +from algokit_utils.applications.app_factory import AppFactoryCreateMethodCallParams +from algokit_utils.clients.algorand_client import AlgorandClient +from algokit_utils.config import config +from algokit_utils.errors.logic_error import LogicError +from algokit_utils.models.amount import AlgoAmount + + +@pytest.fixture +def algorand() -> AlgorandClient: + return AlgorandClient.default_local_net() + + +@pytest.fixture +def funded_account(algorand: AlgorandClient) -> Account: + new_account = algorand.account.random() + dispenser = algorand.account.localnet_dispenser() + algorand.account.ensure_funded(new_account, dispenser, AlgoAmount.from_algos(100)) + return new_account + + +def load_arc32_spec(version: int) -> str: + # Load the appropriate spec file from the resource-packer directory + spec_path = Path(__file__).parent.parent / "artifacts" / "resource-packer" / f"ResourcePackerv{version}.arc32.json" + return spec_path.read_text() + + +class TestResourcePackerAVM8: + """Test resource packing with AVM 8""" + + @pytest.fixture(autouse=True) + def setup(self, algorand: AlgorandClient, funded_account: Account) -> Generator[None, None, None]: + config.configure(populate_app_call_resources=True) + + # Create v8 app + v8_spec = load_arc32_spec(8) + v8_factory = algorand.client.get_app_factory( + app_spec=v8_spec, + default_sender=funded_account.address, + ) + self.v8_client, _ = v8_factory.send.create(params=AppFactoryCreateMethodCallParams(method="createApplication")) + self.v8_client.fund_app_account(FundAppAccountParams(amount=AlgoAmount.from_micro_algo(2334300))) + self.v8_client.send.call( + AppClientMethodCallWithSendParams(method="bootstrap", static_fee=AlgoAmount.from_micro_algo(3_000)) + ) + + yield + + config.configure(populate_app_call_resources=False) + + def test_accounts_address_balance_invalid_ref(self, algorand: AlgorandClient) -> None: + random_account = algorand.account.random() + with pytest.raises(LogicError, match=f"invalid Account reference {random_account.address}"): + self.v8_client.send.call( + AppClientMethodCallWithSendParams( + method="addressBalance", + args=[random_account.address], # Use the address + populate_app_call_resources=False, + ) + ) + + def test_accounts_address_balance_valid_ref(self, algorand: AlgorandClient) -> None: + random_account = algorand.account.random() + self.v8_client.send.call( + AppClientMethodCallWithSendParams( + method="addressBalance", + args=[random_account.address], # Use the address + populate_app_call_resources=True, + ) + ) + + def test_boxes_invalid_ref(self) -> None: + with pytest.raises(LogicError, match="invalid Box reference"): + self.v8_client.send.call( + AppClientMethodCallWithSendParams( + method="smallBox", + populate_app_call_resources=False, + ) + ) + + def test_boxes_valid_ref(self) -> None: + self.v8_client.send.call( + AppClientMethodCallWithSendParams( + method="smallBox", + populate_app_call_resources=True, + ) + ) + + self.v8_client.send.call( + AppClientMethodCallWithSendParams( + method="mediumBox", + populate_app_call_resources=True, + ) + ) + + def test_apps_external_unavailable_app(self) -> None: + with pytest.raises(LogicError, match="unavailable App"): + self.v8_client.send.call( + AppClientMethodCallWithSendParams( + method="externalAppCall", + populate_app_call_resources=False, + static_fee=AlgoAmount.from_micro_algo(2_000), + ) + ) + + def test_apps_external_app(self) -> None: + self.v8_client.send.call( + AppClientMethodCallWithSendParams( + method="externalAppCall", + populate_app_call_resources=True, + static_fee=AlgoAmount.from_micro_algo(2_000), + ) + ) + + def test_assets_unavailable_asset(self) -> None: + with pytest.raises(LogicError, match="unavailable Asset"): + self.v8_client.send.call( + AppClientMethodCallWithSendParams( + method="assetTotal", + populate_app_call_resources=False, + ) + ) + + def test_assets_valid_asset(self) -> None: + self.v8_client.send.call( + AppClientMethodCallWithSendParams( + method="assetTotal", + populate_app_call_resources=True, + ) + ) + + def test_cross_product_reference_invalid_has_asset(self, funded_account: Account) -> None: + with pytest.raises(LogicError, match="unavailable Asset"): + self.v8_client.send.call( + AppClientMethodCallWithSendParams( + method="hasAsset", + args=[funded_account.address], + populate_app_call_resources=False, + ) + ) + + def test_cross_product_reference_has_asset(self, funded_account: Account) -> None: + self.v8_client.send.call( + AppClientMethodCallWithSendParams( + method="hasAsset", + args=[funded_account.address], + populate_app_call_resources=True, + ) + ) + + def test_cross_product_reference_invalid_external_local(self, funded_account: Account) -> None: + with pytest.raises(LogicError, match="unavailable App"): + self.v8_client.send.call( + AppClientMethodCallWithSendParams( + method="externalLocal", + args=[funded_account.address], + populate_app_call_resources=False, + ) + ) diff --git a/tests/transactions/test_transaction_composer.py b/tests/transactions/test_transaction_composer.py index 9217cc08..21bbbcfe 100644 --- a/tests/transactions/test_transaction_composer.py +++ b/tests/transactions/test_transaction_composer.py @@ -1,11 +1,13 @@ +import base64 +from collections.abc import Generator from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any +from unittest.mock import Mock, patch import algosdk import pytest from algosdk.transaction import ( ApplicationCallTxn, - ApplicationCreateTxn, AssetConfigTxn, AssetCreateTxn, PaymentTxn, @@ -35,6 +37,14 @@ def algorand() -> AlgorandClient: return AlgorandClient.default_local_net() +@pytest.fixture(autouse=True) +def mock_config() -> Generator[Mock, None, None]: + with patch("algokit_utils.transactions.transaction_composer.config", new_callable=Mock) as mock_config: + mock_config.debug = True + mock_config.project_root = None + yield mock_config + + @pytest.fixture def funded_account(algorand: AlgorandClient) -> Account: new_account = algorand.account.random() @@ -169,7 +179,7 @@ def test_add_app_create(algorand: AlgorandClient, funded_account: Account) -> No built = composer.build_transactions() assert len(built.transactions) == 1 - assert isinstance(built.transactions[0], ApplicationCreateTxn) + assert isinstance(built.transactions[0], ApplicationCallTxn) txn = built.transactions[0] assert txn.sender == funded_account.address assert txn.approval_program == b"\x06\x81\x01" @@ -261,3 +271,114 @@ def test_arc2_note() -> None: encoded_note = TransactionComposer.arc2_note(note_data) expected_note = b'TestDApp:j{"key":"value"}' assert encoded_note == expected_note + + +def _get_test_transaction( + default_account: Account, amount: AlgoAmount | None = None, sender: Account | None = None +) -> dict[str, Any]: + return { + "sender": sender.address if sender else default_account.address, + "receiver": default_account.address, + "amount": amount or AlgoAmount.from_algos(1), + } + + +def test_transaction_is_capped_by_low_min_txn_fee(algorand: AlgorandClient, funded_account: Account) -> None: + with pytest.raises(ValueError, match="Transaction fee 1000 is greater than max_fee 1 µALGO"): + algorand.send.payment( + PaymentParams(**_get_test_transaction(funded_account), max_fee=AlgoAmount.from_micro_algo(1)) + ) + + +def test_transaction_cap_is_ignored_if_higher_than_fee(algorand: AlgorandClient, funded_account: Account) -> None: + response = algorand.send.payment( + PaymentParams(**_get_test_transaction(funded_account), max_fee=AlgoAmount.from_micro_algo(1_000_000)) + ) + assert response.confirmation["txn"]["txn"]["fee"] == AlgoAmount.from_micro_algo(1000) + + +def test_transaction_fee_is_overridable(algorand: AlgorandClient, funded_account: Account) -> None: + response = algorand.send.payment( + PaymentParams(**_get_test_transaction(funded_account), static_fee=AlgoAmount.from_algos(1)) + ) + assert response.confirmation["txn"]["txn"]["fee"] == AlgoAmount.from_algos(1) + + +def test_transaction_group_is_sent(algorand: AlgorandClient, funded_account: Account) -> None: + composer = TransactionComposer( + algod=algorand.client.algod, + get_signer=lambda _: funded_account.signer, + ) + composer.add_payment(PaymentParams(**_get_test_transaction(funded_account, amount=AlgoAmount.from_algos(1)))) + composer.add_payment(PaymentParams(**_get_test_transaction(funded_account, amount=AlgoAmount.from_algos(2)))) + response = composer.send() + + assert response.confirmations[0]["txn"]["txn"]["grp"] is not None + assert response.confirmations[1]["txn"]["txn"]["grp"] is not None + assert response.transactions[0].payment.group is not None + assert response.transactions[1].payment.group is not None + assert len(response.confirmations) == 2 + assert response.confirmations[0]["confirmed-round"] >= response.transactions[0].payment.first_valid_round + assert response.confirmations[1]["confirmed-round"] >= response.transactions[1].payment.first_valid_round + assert ( + response.confirmations[0]["txn"]["txn"]["grp"] + == base64.b64encode(response.transactions[0].payment.group).decode() + ) + assert ( + response.confirmations[1]["txn"]["txn"]["grp"] + == base64.b64encode(response.transactions[1].payment.group).decode() + ) + + +def test_multisig_single_account(algorand: AlgorandClient, funded_account: Account) -> None: + multisig = algorand.account.multi_sig( + version=1, threshold=1, addrs=[funded_account.address], signing_accounts=[funded_account] + ) + algorand.send.payment( + PaymentParams(sender=funded_account.address, receiver=multisig.address, amount=AlgoAmount.from_algos(1)) + ) + algorand.send.payment( + PaymentParams(sender=multisig.address, receiver=funded_account.address, amount=AlgoAmount.from_micro_algo(500)) + ) + + +def test_multisig_double_account(algorand: AlgorandClient, funded_account: Account) -> None: + account2 = algorand.account.random() + algorand.account.ensure_funded(account2, funded_account, AlgoAmount.from_algos(10)) + + # Setup multisig + multisig = algorand.account.multi_sig( + version=1, + threshold=2, + addrs=[funded_account.address, account2.address], + signing_accounts=[funded_account, account2], + ) + + # Fund multisig + algorand.send.payment( + PaymentParams(sender=funded_account.address, receiver=multisig.address, amount=AlgoAmount.from_algos(1)) + ) + + # Use multisig + algorand.send.payment( + PaymentParams(sender=multisig.address, receiver=funded_account.address, amount=AlgoAmount.from_micro_algo(500)) + ) + + +@pytest.mark.usefixtures("mock_config") +def test_transactions_fails_in_debug_mode(algorand: AlgorandClient, funded_account: Account) -> None: + txn1 = algorand.create_transaction.payment(PaymentParams(**_get_test_transaction(funded_account))) + txn2 = algorand.create_transaction.payment( + PaymentParams(**_get_test_transaction(funded_account, amount=AlgoAmount.from_micro_algo(9999999999999))) + ) + composer = TransactionComposer( + algod=algorand.client.algod, + get_signer=lambda _: funded_account.signer, + ) + composer.add_transaction(txn1) + composer.add_transaction(txn2) + + with pytest.raises(Exception) as e: # noqa: PT011 + composer.send() + + assert f"transaction {txn2.get_txid()}: overspend" in e.value.traces[0]["failure_message"] # type: ignore[attr-defined] diff --git a/tests/transactions/test_transaction_creator.py b/tests/transactions/test_transaction_creator.py index 3c944041..e7cd9dcd 100644 --- a/tests/transactions/test_transaction_creator.py +++ b/tests/transactions/test_transaction_creator.py @@ -4,7 +4,6 @@ import pytest from algosdk.transaction import ( ApplicationCallTxn, - ApplicationCreateTxn, AssetConfigTxn, AssetCreateTxn, AssetDestroyTxn, @@ -212,7 +211,7 @@ def test_create_app_create_transaction(algorand: AlgorandClient, funded_account: ) ) - assert isinstance(txn, ApplicationCreateTxn) + assert isinstance(txn, ApplicationCallTxn) assert txn.sender == funded_account.address assert txn.approval_program == b"\x06\x81\x01" assert txn.clear_program == b"\x06\x81\x01" diff --git a/tests/transactions/test_transaction_sender.py b/tests/transactions/test_transaction_sender.py index def636fd..975ce5d2 100644 --- a/tests/transactions/test_transaction_sender.py +++ b/tests/transactions/test_transaction_sender.py @@ -3,6 +3,7 @@ import algosdk import pytest +from algosdk.transaction import OnComplete from algokit_utils import Account from algokit_utils._legacy_v2.application_specification import ApplicationSpecification @@ -402,6 +403,7 @@ def test_app_call( params = AppCallParams( app_id=test_hello_world_arc32_app_id, sender=sender.address, + on_complete=OnComplete.NoOpOC, args=[b"\x02\xbe\xce\x11", b"test"], )