From 25c084bcc1a6da4fc6332d500c9fbdfa9e3e0b9b Mon Sep 17 00:00:00 2001 From: Altynbek Orumbayev Date: Mon, 9 Dec 2024 00:56:21 +0100 Subject: [PATCH] chore: wip --- src/algokit_utils/applications/app_client.py | 9 +- src/algokit_utils/applications/app_manager.py | 3 +- src/algokit_utils/applications/utils.py | 3 +- src/algokit_utils/errors/logic_error.py | 8 +- src/algokit_utils/models/application.py | 138 ++++++++++++++++-- tests/applications/test_app_factory.py | 51 ++++++- 6 files changed, 181 insertions(+), 31 deletions(-) diff --git a/src/algokit_utils/applications/app_client.py b/src/algokit_utils/applications/app_client.py index c3556b0..6929b68 100644 --- a/src/algokit_utils/applications/app_client.py +++ b/src/algokit_utils/applications/app_client.py @@ -65,12 +65,6 @@ T = TypeVar("T") # For generic return type in _handle_call_errors -def camel_to_snake_case(name: str) -> str: - import re - - return re.sub(r"(? int: # noqa: C901 """Calculate the offset after constant blocks in TEAL program. @@ -885,8 +879,7 @@ def normalise_app_spec(app_spec: Arc56Contract | ApplicationSpecification | str) return arc32_to_arc56(spec) elif isinstance(spec, dict): # normalize field names to lowercase to python camel - transformed_spec = {camel_to_snake_case(k): v for k, v in spec.items()} - return Arc56Contract(**transformed_spec) + return Arc56Contract.from_json(spec) else: raise ValueError("Invalid app spec format") diff --git a/src/algokit_utils/applications/app_manager.py b/src/algokit_utils/applications/app_manager.py index 8cf1ada..9ebe9ee 100644 --- a/src/algokit_utils/applications/app_manager.py +++ b/src/algokit_utils/applications/app_manager.py @@ -10,6 +10,7 @@ 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 ABIType, ABIValue @@ -169,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 diff --git a/src/algokit_utils/applications/utils.py b/src/algokit_utils/applications/utils.py index 37c08a7..ecccdb3 100644 --- a/src/algokit_utils/applications/utils.py +++ b/src/algokit_utils/applications/utils.py @@ -17,7 +17,6 @@ Arc56Contract, Arc56ContractState, Arc56Method, - ARCType, CallConfig, DefaultValue, Method, @@ -399,7 +398,7 @@ def get_action_value(key: str) -> str | None: } return Arc56Contract( - arcs=[ARCType.ARC56], + arcs=[], name=app_spec.contract.name, desc=app_spec.contract.desc, structs=structs, diff --git a/src/algokit_utils/errors/logic_error.py b/src/algokit_utils/errors/logic_error.py index 2951457..e8c3665 100644 --- a/src/algokit_utils/errors/logic_error.py +++ b/src/algokit_utils/errors/logic_error.py @@ -1,3 +1,4 @@ +import base64 import dataclasses import re from copy import copy @@ -68,9 +69,12 @@ def __init__( ): self.logic_error = logic_error self.logic_error_str = logic_error_str - self.program = program + try: + self.program = base64.b64decode(program).decode("utf-8") + except Exception: + self.program = program self.source_map = source_map - self.lines = program.split("\n") + self.lines = self.program.split("\n") self.transaction_id = transaction_id self.message = message self.pc = pc diff --git a/src/algokit_utils/models/application.py b/src/algokit_utils/models/application.py index 87943a5..0d08273 100644 --- a/src/algokit_utils/models/application.py +++ b/src/algokit_utils/models/application.py @@ -1,5 +1,5 @@ +import json from dataclasses import asdict, dataclass, field, is_dataclass -from enum import IntEnum from typing import Any, Literal import algosdk @@ -25,6 +25,20 @@ 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 + + @dataclass class CallConfig: no_op: str | None = None @@ -35,11 +49,6 @@ class CallConfig: delete_application: str | None = None -class ARCType(IntEnum): - ARC56 = 56 - ARC32 = 32 - - @dataclass(kw_only=True) class StructField: name: str @@ -194,7 +203,7 @@ def __init__(self, method: Method): @dataclass(kw_only=True) class Arc56Contract: - arcs: list[ARCType] + arcs: list[int] name: str desc: str | None = None networks: dict[str, dict[str, int]] | None = None @@ -210,15 +219,112 @@ class Arc56Contract: template_variables: dict[str, dict[str, ABITypeAlias | AVMType | StructName | str]] | None = None scratch_variables: dict[str, dict[str, int | ABITypeAlias | AVMType | StructName]] | None = None - def __init__(self, **kwargs: Any) -> None: - if isinstance(kwargs.get("state"), dict): - kwargs["state"] = Arc56ContractState(**kwargs["state"]) - if isinstance(kwargs.get("methods"), list): - kwargs["methods"] = [Method(**method) for method in kwargs["methods"]] - if isinstance(kwargs.get("source_info"), dict): - kwargs["source_info"] = {k: ProgramSourceInfo(**v) for k, v in kwargs["source_info"].items()} - - super().__init__(**kwargs) + @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(**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) diff --git a/tests/applications/test_app_factory.py b/tests/applications/test_app_factory.py index fca3fbf..b89d31e 100644 --- a/tests/applications/test_app_factory.py +++ b/tests/applications/test_app_factory.py @@ -5,6 +5,7 @@ from algosdk.logic import get_application_address from algosdk.transaction import ApplicationCallTxn, ApplicationCreateTxn, OnComplete +from algokit_utils import OnSchemaBreak, OnUpdate, OperationPerformed from algokit_utils.applications.app_client import ( AppClient, AppClientMethodCallParams, @@ -452,7 +453,53 @@ def test_arc56_error_messages_with_dynamic_template_vars_cblock_offset( }, ) - with pytest.raises(Exception) as exc_info: + with pytest.raises(LogicError) as exc_info: client.send.call(AppClientMethodCallWithSendParams(method="throwError")) - assert "this is an error" in str(exc_info.value) + assert "this is an error" in exc_info.value.stack + + +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")) + + expected_error = """log + +// tests/example-contracts/arc56_templates/templates.algo.ts:14 +// assert(this.uint64TmplVar) +intc 1 // TMPL_uint64TmplVar +assert <--- Error +retsub + +// specificLengthTemplateVar()void +*abi_route_specificLengthTemplateVar:""" + + assert expected_error in exc_info.value.stack