diff --git a/.vscode/settings.json b/.vscode/settings.json index e570b2a..8eca96b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -16,18 +16,25 @@ "**/__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": [ + "**/artifacts/**", + "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 +44,6 @@ "ruff.codeAction.fixViolation": { "enable": true }, - "mypy.configFile": "pyproject.toml", // set to empty array to use config from project "mypy.targets": [], @@ -52,11 +58,4 @@ } ] }, - - // PowerShell - "[powershell]": { - "editor.defaultFormatter": "ms-vscode.powershell" - }, - "powershell.codeFormatting.preset": "Stroustrup", - "python.testing.pytestArgs": ["."] } diff --git a/pyproject.toml b/pyproject.toml index e22173c..1477aaa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -106,6 +106,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,7 +114,7 @@ extend-exclude = [ ".git", ".mypy_cache", ".ruff_cache", - + "tests/artifacts", ] # Assume Python 3.10. target-version = "py310" @@ -127,7 +128,7 @@ 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"] "tests/clients/test_algorand_client.py" = ["ERA001"] [tool.poe.tasks] @@ -140,7 +141,7 @@ pythonpath = ["src", "tests"] [tool.mypy] files = ["src", "tests"] -exclude = ["dist"] +exclude = ["dist", "tests/artifacts"] python_version = "3.10" warn_unused_ignores = true warn_redundant_casts = true diff --git a/src/algokit_utils/_legacy_v2/deploy.py b/src/algokit_utils/_legacy_v2/deploy.py index 799fe08..0aadb72 100644 --- a/src/algokit_utils/_legacy_v2/deploy.py +++ b/src/algokit_utils/_legacy_v2/deploy.py @@ -255,7 +255,7 @@ class AppChanges: schema_change_description: str | None -def check_for_app_changes( # noqa: PLR0913 +def check_for_app_changes( algod_client: "AlgodClient", *, new_approval: bytes, diff --git a/src/algokit_utils/_legacy_v2/logic_error.py b/src/algokit_utils/_legacy_v2/logic_error.py index a365a3c..0878887 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 deprecated import deprecated + from algokit_utils._legacy_v2.models import SimulationTrace if TYPE_CHECKING: @@ -37,8 +39,9 @@ def parse_logic_error( } +@deprecated(reason="Use algokit_utils.models.error.LogicError instead", version="3.0.0") class LogicError(Exception): - def __init__( # noqa: PLR0913 + def __init__( self, *, logic_error_str: str, diff --git a/src/algokit_utils/applications/app_client.py b/src/algokit_utils/applications/app_client.py index 69fc31a..4805d79 100644 --- a/src/algokit_utils/applications/app_client.py +++ b/src/algokit_utils/applications/app_client.py @@ -5,7 +5,7 @@ import json import os from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Protocol +from typing import TYPE_CHECKING, Any, Protocol, TypeVar import algosdk from algosdk.transaction import OnComplete, Transaction @@ -18,7 +18,16 @@ get_abi_tuple_from_abi_struct, get_arc56_method, ) -from algokit_utils.models.application import AppState, Arc56Contract, CompiledTeal, StorageKey, StorageMap +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, @@ -53,6 +62,8 @@ BYTE_CBLOCK = 0x20 # bytecblock opcode INT_CBLOCK = 0x21 # 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. @@ -123,12 +134,6 @@ class AppClientCompilationParams: deletable: bool | None = None -@dataclass(frozen=True, kw_only=True) -class ProgramSourceInfo: - pc_offset_method: str | None - source_info: list[dict[str, Any]] - - @dataclass(frozen=True, kw_only=True) class ExposedLogicErrorDetails: is_clear_state_program: bool = False @@ -354,9 +359,9 @@ def get_map(self, map_name: str) -> dict[str, ABIValue]: class _AppClientStateAccessor: def __init__(self, client: AppClient) -> None: self._client = client - self._algorand = client._algorand # noqa: SLF001 - self._app_id = client._app_id # noqa: SLF001 - self._app_spec = client._app_spec # noqa: SLF001 + 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""" @@ -465,9 +470,9 @@ def get_global_state(self) -> dict[str, AppState]: class _AppClientBareParamsAccessor: def __init__(self, client: AppClient) -> None: self._client = client - self._algorand = client._algorand # noqa: SLF001 - self._app_id = client._app_id # noqa: SLF001 - self._app_spec = client._app_spec # noqa: SLF001 + 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 @@ -523,9 +528,9 @@ def call(self, params: AppClientBareCallWithCallOnCompleteParams) -> AppCallPara class _AppClientMethodCallParamsAccessor: def __init__(self, client: AppClient) -> None: self._client = client - self._algorand = client._algorand # noqa: SLF001 - self._app_id = client._app_id # noqa: SLF001 - self._app_spec = client._app_spec # noqa: SLF001 + self._algorand = client._algorand + self._app_id = client._app_id + self._app_spec = client._app_spec self._bare_params_accessor = _AppClientBareParamsAccessor(client) @property @@ -583,16 +588,16 @@ def _get_abi_params(self, params: dict[str, Any], on_complete: algosdk.transacti input_params["app_id"] = self._app_id input_params["on_complete"] = on_complete - input_params["sender"] = self._client._get_sender(params["sender"]) # noqa: SLF001 - input_params["signer"] = self._client._get_signer(params["sender"], params["signer"]) # noqa: SLF001 + 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( # noqa: SLF001 + 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"]), # noqa: SLF001 + sender=self._client._get_sender(input_params["sender"]), ) return input_params @@ -601,7 +606,7 @@ def _get_abi_params(self, params: dict[str, Any], on_complete: algosdk.transacti class _AppClientBareCreateTransactionMethods: def __init__(self, client: AppClient) -> None: self._client = client - self._algorand = client._algorand # noqa: SLF001 + self._algorand = client._algorand def update(self, params: AppClientBareCallWithCompilationAndSendParams) -> Transaction: return self._algorand.create_transaction.app_update(self._client.params.bare.update(params)) @@ -625,9 +630,9 @@ def call(self, params: AppClientBareCallWithCallOnCompleteParams) -> Transaction class _AppClientMethodCallTransactionCreator: def __init__(self, client: AppClient) -> None: self._client = client - self._algorand = client._algorand # noqa: SLF001 - self._app_id = client._app_id # noqa: SLF001 - self._app_spec = client._app_spec # noqa: SLF001 + self._algorand = client._algorand + self._app_id = client._app_id + self._app_spec = client._app_spec self._bare_create_transaction_methods = _AppClientBareCreateTransactionMethods(client) @property @@ -656,9 +661,9 @@ def call(self, params: AppClientMethodCallParams) -> BuiltTransactions: class _AppClientBareSendAccessor: def __init__(self, client: AppClient) -> None: self._client = client - self._algorand = client._algorand # noqa: SLF001 - self._app_id = client._app_id # noqa: SLF001 - self._app_spec = client._app_spec # noqa: SLF001 + self._algorand = client._algorand + self._app_id = client._app_id + self._app_spec = client._app_spec def update( self, @@ -682,31 +687,41 @@ def update( 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._algorand.send.app_update(bare_params) + 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._algorand.send.app_call(self._client.params.bare.opt_in(params)) + 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._algorand.send.app_call(self._client.params.bare.delete(params)) + 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._algorand.send.app_call(self._client.params.bare.clear_state(params)) + 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._algorand.send.app_call(self._client.params.bare.close_out(params)) + 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._algorand.send.app_call(self._client.params.bare.call(params)) + 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 # noqa: SLF001 - self._app_id = client._app_id # noqa: SLF001 - self._app_spec = client._app_spec # noqa: SLF001 + self._algorand = client._algorand + self._app_id = client._app_id + self._app_spec = client._app_spec self._bare_send_accessor = _AppClientBareSendAccessor(client) @property @@ -714,19 +729,29 @@ def bare(self) -> _AppClientBareSendAccessor: return self._bare_send_accessor def fund_app_account(self, params: FundAppAccountParams) -> SendSingleTransactionResult: - return self._algorand.send.payment(self._client.params.fund_app_account(params)) + 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._algorand.send.app_call_method_call(self._client.params.opt_in(params)) + 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._algorand.send.app_delete_method_call(self._client.params.delete(params)) + 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._algorand.send.app_update_method_call(self._client.params.update(params)) + 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._algorand.send.app_call_method_call(self._client.params.close_out(params)) + 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 = ( @@ -740,15 +765,17 @@ def call(self, params: AppClientMethodCallWithSendParams) -> SendAppTransactionR self._client.params.call(params) ) - simulate_response = 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 + 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( @@ -763,7 +790,9 @@ def call(self, params: AppClientMethodCallWithSendParams) -> SendAppTransactionR return_value=simulate_response.returns[-1].return_value, ) - return self._algorand.send.app_call_method_call(self._client.params.call(params)) + return self._client._handle_call_errors( + lambda: self._algorand.send.app_call_method_call(self._client.params.call(params)) + ) class AppClient: @@ -914,7 +943,6 @@ def compile( ) # TODO: Add invocation of persisting sourcemaps - return AppClientCompilationResult( approval_program=compiled_approval.compiled_base64_to_bytes, compiled_approval=compiled_approval, @@ -922,6 +950,78 @@ def compile( compiled_clear=compiled_clear, ) + @staticmethod + def expose_logic_error_static( + 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 + ) + 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, + 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, @@ -1007,6 +1107,63 @@ def new_group(self) -> TransactionComposer: 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( diff --git a/src/algokit_utils/errors/logic_error.py b/src/algokit_utils/errors/logic_error.py new file mode 100644 index 0000000..b3d9c12 --- /dev/null +++ b/src/algokit_utils/errors/logic_error.py @@ -0,0 +1,116 @@ +import dataclasses +import re +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]+).*" +) + + +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, + ): + 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]) + + +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/transactions/models.py b/src/algokit_utils/transactions/models.py index ff9e6bc..251bbf9 100644 --- a/src/algokit_utils/transactions/models.py +++ b/src/algokit_utils/transactions/models.py @@ -1,7 +1,5 @@ from typing import Any, Literal, TypedDict -from algosdk.v2client.models.simulate_request import SimulateTraceConfig - # Define specific types for different formats class BaseArc2Note(TypedDict): diff --git a/tests/applications/test_app_client.py b/tests/applications/test_app_client.py index b485d73..9256526 100644 --- a/tests/applications/test_app_client.py +++ b/tests/applications/test_app_client.py @@ -1,4 +1,5 @@ import base64 +import json from pathlib import Path from typing import Any @@ -15,6 +16,7 @@ from algokit_utils.applications.app_manager import AppManager, BoxReference from algokit_utils.applications.utils import arc32_to_arc56 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 @@ -123,6 +125,29 @@ def test_app_client( ) +@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" @@ -553,3 +578,14 @@ def test_abi_with_default_arg_method( # Test with default value default_value_result = app_client.send.call(AppClientMethodCallWithSendParams(method=method_signature, args=[None])) assert default_value_result.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 # noqa: PLR2004 + assert "assert failed pc=885" in str(error) + assert len(error.transaction_id) == 52 # noqa: PLR2004 + assert error.line_no == 469 # noqa: PLR2004 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 0000000..9ee4339 --- /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": {} + } +}