diff --git a/.claude/skills/linear-issue-fixer/SKILL.md b/.claude/skills/linear-issue-fixer/SKILL.md index 39b434076..30f6ccffd 100644 --- a/.claude/skills/linear-issue-fixer/SKILL.md +++ b/.claude/skills/linear-issue-fixer/SKILL.md @@ -182,6 +182,7 @@ Create a clear implementation plan: Co-Authored-By: Claude " ``` + ### Step 7: Run ALL Test Suites **IMPORTANT: You MUST run ALL of the following test suites before creating a PR. Do NOT skip any.** diff --git a/backend/node/base.py b/backend/node/base.py index 229d253d6..00c914b19 100644 --- a/backend/node/base.py +++ b/backend/node/base.py @@ -994,6 +994,9 @@ async def _run_genvm( perms += "ws" # write/send start_time = time.time() + # Only enforce version restrictions for new deployments (is_init=True) + # For running existing contracts, allow debug mode for flexibility + extra_args = [] if is_init else ["--debug-mode"] try: result = await genvmbase.run_genvm_host( functools.partial( @@ -1006,8 +1009,8 @@ async def _run_genvm( permissions=perms, capture_output=True, host_data=json.dumps(host_data), - extra_args=["--debug-mode"], - is_sync=is_sync, + extra_args=extra_args, + is_sync=False, manager_uri=self.manager.url, timeout=timeout, code=code, diff --git a/backend/node/genvm/base.py b/backend/node/genvm/base.py index fea64efe5..ce3989f9d 100644 --- a/backend/node/genvm/base.py +++ b/backend/node/genvm/base.py @@ -47,6 +47,7 @@ parse_module_error_string, parse_ctx_from_module_error_string, GenVMInternalError, + get_user_friendly_message, ) @@ -284,8 +285,14 @@ def provide_result( # Preserve raw error structure (causes, fatal, ctx) excluding message raw_error = {k: v for k, v in result_decoded.items() if k != "message"} + # Get user-friendly message if available + original_message = result_decoded["message"] + friendly_message = get_user_friendly_message( + error_code, original_message, raw_error + ) + result = ExecutionError( - result_decoded["message"], + friendly_message, res.result_kind, error_code=error_code, raw_error=raw_error if raw_error else None, @@ -294,8 +301,12 @@ def provide_result( else: # String error - try to extract error code from message error_code = extract_error_code(str(result_decoded), res.stderr) + original_message = str(result_decoded) + friendly_message = get_user_friendly_message( + error_code, original_message + ) result = ExecutionError( - str(result_decoded), + friendly_message, res.result_kind, error_code=error_code, ) diff --git a/backend/node/genvm/error_codes.py b/backend/node/genvm/error_codes.py index 854c34c92..3ce81687b 100644 --- a/backend/node/genvm/error_codes.py +++ b/backend/node/genvm/error_codes.py @@ -20,6 +20,7 @@ "RATE_LIMIT_STATUSES", "extract_error_code", "parse_module_error_string", + "get_user_friendly_message", "parse_ctx_from_module_error_string", ) @@ -80,12 +81,61 @@ class GenVMErrorCode(StrEnum): WEB_REQUEST_FAILED = "WEB_REQUEST_FAILED" WEB_TLD_FORBIDDEN = "WEB_TLD_FORBIDDEN" + # Contract/runner errors + INVALID_RUNNER = "INVALID_RUNNER" + # Execution errors GENVM_TIMEOUT = "GENVM_TIMEOUT" CONTRACT_ERROR = "CONTRACT_ERROR" INTERNAL_ERROR = "INTERNAL_ERROR" +# User-friendly error messages for each error code +ERROR_CODE_MESSAGES: dict[str, str] = { + GenVMErrorCode.INVALID_RUNNER: ( + "Invalid runner version. The 'latest' and 'test' versions are not allowed. " + "Please use a fixed version hash in your contract header, e.g.: " + '# { "Depends": "py-genlayer:1jb45aa8ynh2a9c9xn3b7qqh8sm5q93hwfp7jqmwsfhh8jpz09h6" }' + ), + GenVMErrorCode.LLM_RATE_LIMITED: "LLM provider rate limit exceeded. Please try again later.", + GenVMErrorCode.LLM_NO_PROVIDER: "No LLM provider available for the requested operation.", + GenVMErrorCode.LLM_PROVIDER_ERROR: "LLM provider returned an error.", + GenVMErrorCode.LLM_INVALID_API_KEY: "Invalid API key for the LLM provider.", + GenVMErrorCode.LLM_TIMEOUT: "LLM request timed out.", + GenVMErrorCode.WEB_REQUEST_FAILED: "Web request failed.", + GenVMErrorCode.WEB_TLD_FORBIDDEN: "Access to the requested domain is forbidden.", + GenVMErrorCode.GENVM_TIMEOUT: "Contract execution timed out.", + GenVMErrorCode.CONTRACT_ERROR: "Contract execution error.", + GenVMErrorCode.INTERNAL_ERROR: "Internal GenVM error.", +} + + +def get_user_friendly_message( + error_code: str | None, original_message: str, raw_error: dict | None = None +) -> str: + """ + Get a user-friendly error message based on the error code. + + Args: + error_code: The standardized error code (e.g., INVALID_RUNNER) + original_message: The original error message from GenVM + raw_error: Optional raw error structure with causes + + Returns: + A user-friendly error message + """ + if error_code and error_code in ERROR_CODE_MESSAGES: + return ERROR_CODE_MESSAGES[error_code] + + # Fallback: check raw_error causes for invalid runner pattern + if raw_error and isinstance(raw_error.get("causes"), list): + for cause in raw_error["causes"]: + if isinstance(cause, str) and "invalid runner id" in cause.lower(): + return ERROR_CODE_MESSAGES[GenVMErrorCode.INVALID_RUNNER] + + return original_message + + # Mapping from Lua error causes to standardized error codes LUA_CAUSE_TO_CODE: dict[str, GenVMErrorCode] = { "NO_PROVIDER_FOR_PROMPT": GenVMErrorCode.LLM_NO_PROVIDER, @@ -311,6 +361,9 @@ def extract_error_code( # Map Lua causes to error codes if isinstance(causes, list): for cause in causes: + # Check for invalid runner error (e.g., "invalid runner id: py-genlayer:latest") + if isinstance(cause, str) and "invalid runner id" in cause.lower(): + return GenVMErrorCode.INVALID_RUNNER if cause in LUA_CAUSE_TO_CODE: # Special case: STATUS_NOT_OK might be rate limiting based on ctx if cause == "STATUS_NOT_OK": @@ -327,6 +380,10 @@ def _extract_from_message(message: str, stderr: str = "") -> Optional[str]: """Extract error code from message string or stderr.""" combined = f"{message} {stderr}".lower() + # Check for invalid runner (e.g., using :latest or :test in non-debug mode) + if "invalid runner" in combined: + return GenVMErrorCode.INVALID_RUNNER + # Check for specific error patterns if "rate limit" in combined or "429" in combined: return GenVMErrorCode.LLM_RATE_LIMITED diff --git a/backend/protocol_rpc/contract_linter.py b/backend/protocol_rpc/contract_linter.py index 3337d5022..787049860 100644 --- a/backend/protocol_rpc/contract_linter.py +++ b/backend/protocol_rpc/contract_linter.py @@ -3,8 +3,40 @@ Provides validation and linting for GenLayer smart contracts. """ -from typing import Dict, Any -from backend.protocol_rpc.exceptions import JSONRPCError +import re +from typing import Dict, List, Any, Optional, Tuple +from flask_jsonrpc.exceptions import JSONRPCError + + +# Pattern to match the Depends header with "latest" or "test" versions +# Matches: # { "Depends": "py-genlayer:latest" } or # { "Depends": "py-genlayer:test" } +INVALID_VERSION_PATTERN = re.compile( + r'#\s*\{\s*"Depends"\s*:\s*"py-genlayer:(latest|test)"\s*\}' +) + +# User-friendly error message for invalid runner versions +# Note: Curly braces in the example are escaped for .format() compatibility +INVALID_VERSION_ERROR_MESSAGE = ( + 'Invalid runner version "{version}". The "latest" and "test" versions are not allowed. ' + "Please use a fixed version hash in your contract header, e.g.: " + '# {{ "Depends": "py-genlayer:1jb45aa8ynh2a9c9xn3b7qqh8sm5q93hwfp7jqmwsfhh8jpz09h6" }}' +) + + +def check_invalid_runner_version(source_code: str) -> Tuple[bool, Optional[str]]: + """ + Check if source code contains an invalid runner version (latest or test). + + Args: + source_code: Python source code to check + + Returns: + Tuple of (has_invalid_version, version_name or None) + """ + match = INVALID_VERSION_PATTERN.search(source_code) + if match: + return True, match.group(1) + return False, None class ContractLinter: @@ -13,6 +45,40 @@ class ContractLinter: def __init__(self): """Initialize the ContractLinter.""" + def _check_invalid_runner_version( + self, source_code: str, filename: str + ) -> List[Dict[str, Any]]: + """ + Check for invalid runner versions (latest, test) in the contract header. + + Args: + source_code: Python source code to check + filename: Filename for error reporting + + Returns: + List of linting issues found + """ + issues = [] + lines = source_code.split("\n") + + for line_num, line in enumerate(lines, start=1): + match = INVALID_VERSION_PATTERN.search(line) + if match: + version = match.group(1) + issues.append( + { + "rule_id": "INVALID_RUNNER_VERSION", + "message": f'The runner version "{version}" is not allowed. Use a fixed version hash instead.', + "severity": "error", + "line": line_num, + "column": match.start() + 1, + "filename": filename, + "suggestion": 'Use a fixed version hash, e.g.: # { "Depends": "py-genlayer:1jb45aa8ynh2a9c9xn3b7qqh8sm5q93hwfp7jqmwsfhh8jpz09h6" }', + } + ) + + return issues + def lint_contract( self, source_code: str, filename: str = "contract.py" ) -> Dict[str, Any]: @@ -40,7 +106,7 @@ def lint_contract( linter = GenVMLinter() results = linter.lint_source(source_code, filename) - print(f"[LINTER] Found {len(results)} issues") + print(f"[LINTER] Found {len(results)} issues from genvm_linter") # Convert results to JSON-serializable format results_json = [] @@ -61,9 +127,19 @@ def lint_contract( } ) + # Add custom linting rules + custom_issues = self._check_invalid_runner_version(source_code, filename) + for issue in custom_issues: + severity_counts[issue["severity"]] += 1 + results_json.append(issue) + + print( + f"[LINTER] Total issues: {len(results_json)} ({len(custom_issues)} from custom rules)" + ) + return { "results": results_json, - "summary": {"total": len(results), "by_severity": severity_counts}, + "summary": {"total": len(results_json), "by_severity": severity_counts}, } except ImportError as e: print(f"[LINTER] Import error: {e}") diff --git a/backend/protocol_rpc/endpoints.py b/backend/protocol_rpc/endpoints.py index 348f053b6..8c0c723b8 100644 --- a/backend/protocol_rpc/endpoints.py +++ b/backend/protocol_rpc/endpoints.py @@ -629,6 +629,20 @@ def admin_upgrade_contract_code( data={}, ) + # Validate runner version is not 'latest' or 'test' + from backend.protocol_rpc.contract_linter import ( + check_invalid_runner_version, + INVALID_VERSION_ERROR_MESSAGE, + ) + + has_invalid_version, version = check_invalid_runner_version(new_code) + if has_invalid_version: + raise JSONRPCError( + code=-32602, + message=INVALID_VERSION_ERROR_MESSAGE.format(version=version), + data={"invalid_version": version}, + ) + # Validate contract exists and is deployed contract = session.query(CurrentState).filter_by(id=contract_address).one_or_none() if not contract or not contract.data or not contract.data.get("state"): diff --git a/tests/integration/icontracts/contracts/company_naming.py b/tests/integration/icontracts/contracts/company_naming.py index 097240de6..9a689a17c 100644 --- a/tests/integration/icontracts/contracts/company_naming.py +++ b/tests/integration/icontracts/contracts/company_naming.py @@ -1,4 +1,4 @@ -# { "Depends": "py-genlayer:test" } +# { "Depends": "py-genlayer:1jb45aa8ynh2a9c9xn3b7qqh8sm5q93hwfp7jqmwsfhh8jpz09h6" } import json from genlayer import * diff --git a/tests/integration/icontracts/contracts/error_execution_contract.py b/tests/integration/icontracts/contracts/error_execution_contract.py index a5d96c560..6853eff0c 100644 --- a/tests/integration/icontracts/contracts/error_execution_contract.py +++ b/tests/integration/icontracts/contracts/error_execution_contract.py @@ -1,5 +1,5 @@ # v0.1.0 -# { "Depends": "py-genlayer:test" } +# { "Depends": "py-genlayer:1jb45aa8ynh2a9c9xn3b7qqh8sm5q93hwfp7jqmwsfhh8jpz09h6" } from genlayer import * diff --git a/tests/integration/icontracts/contracts/error_llm_contract.py b/tests/integration/icontracts/contracts/error_llm_contract.py index 9618fc04f..d718e8af9 100644 --- a/tests/integration/icontracts/contracts/error_llm_contract.py +++ b/tests/integration/icontracts/contracts/error_llm_contract.py @@ -1,5 +1,5 @@ # v0.1.0 -# { "Depends": "py-genlayer:test" } +# { "Depends": "py-genlayer:1jb45aa8ynh2a9c9xn3b7qqh8sm5q93hwfp7jqmwsfhh8jpz09h6" } from genlayer import * import json diff --git a/tests/integration/icontracts/contracts/error_web_contract.py b/tests/integration/icontracts/contracts/error_web_contract.py index 246ca10bb..708163307 100644 --- a/tests/integration/icontracts/contracts/error_web_contract.py +++ b/tests/integration/icontracts/contracts/error_web_contract.py @@ -1,5 +1,5 @@ # v0.1.0 -# { "Depends": "py-genlayer:test" } +# { "Depends": "py-genlayer:1jb45aa8ynh2a9c9xn3b7qqh8sm5q93hwfp7jqmwsfhh8jpz09h6" } from genlayer import * diff --git a/tests/integration/icontracts/contracts/intelligent_oracle.py b/tests/integration/icontracts/contracts/intelligent_oracle.py index f5af7de7c..0aa5474c4 100644 --- a/tests/integration/icontracts/contracts/intelligent_oracle.py +++ b/tests/integration/icontracts/contracts/intelligent_oracle.py @@ -1,5 +1,5 @@ # v0.1.0 -# { "Depends": "py-genlayer:test" } +# { "Depends": "py-genlayer:1jb45aa8ynh2a9c9xn3b7qqh8sm5q93hwfp7jqmwsfhh8jpz09h6" } import json from enum import Enum diff --git a/tests/integration/icontracts/contracts/intelligent_oracle_factory.py b/tests/integration/icontracts/contracts/intelligent_oracle_factory.py index 838063e09..949281196 100644 --- a/tests/integration/icontracts/contracts/intelligent_oracle_factory.py +++ b/tests/integration/icontracts/contracts/intelligent_oracle_factory.py @@ -1,5 +1,5 @@ # v0.1.0 -# { "Depends": "py-genlayer:test" } +# { "Depends": "py-genlayer:1jb45aa8ynh2a9c9xn3b7qqh8sm5q93hwfp7jqmwsfhh8jpz09h6" } from genlayer import * diff --git a/tests/integration/icontracts/contracts/multi_file_contract/__init__.py b/tests/integration/icontracts/contracts/multi_file_contract/__init__.py index 8c5ce6ce8..5d14b6011 100644 --- a/tests/integration/icontracts/contracts/multi_file_contract/__init__.py +++ b/tests/integration/icontracts/contracts/multi_file_contract/__init__.py @@ -1,3 +1,6 @@ +# v0.1.0 +# { "Depends": "py-genlayer:1jb45aa8ynh2a9c9xn3b7qqh8sm5q93hwfp7jqmwsfhh8jpz09h6" } + from genlayer import * diff --git a/tests/integration/icontracts/contracts/multi_file_contract/other.py b/tests/integration/icontracts/contracts/multi_file_contract/other.py index 330de3447..690aed9ac 100644 --- a/tests/integration/icontracts/contracts/multi_file_contract/other.py +++ b/tests/integration/icontracts/contracts/multi_file_contract/other.py @@ -1,4 +1,4 @@ -# { "Depends": "py-genlayer:test" } +# { "Depends": "py-genlayer:1jb45aa8ynh2a9c9xn3b7qqh8sm5q93hwfp7jqmwsfhh8jpz09h6" } from genlayer import * diff --git a/tests/integration/icontracts/contracts/multi_file_contract/runner.json b/tests/integration/icontracts/contracts/multi_file_contract/runner.json index c3e448f9a..fb4c1b620 100644 --- a/tests/integration/icontracts/contracts/multi_file_contract/runner.json +++ b/tests/integration/icontracts/contracts/multi_file_contract/runner.json @@ -1,3 +1,3 @@ { - "Depends": "py-genlayer-multi:test" + "Depends": "py-genlayer-multi:06zyvrlivjga0d5jlpdbprksc0pa6jmllxvp8s20hq1l512vh5yk" } diff --git a/tests/integration/icontracts/contracts/multi_read_erc20.py b/tests/integration/icontracts/contracts/multi_read_erc20.py index 195830268..b22704f1f 100644 --- a/tests/integration/icontracts/contracts/multi_read_erc20.py +++ b/tests/integration/icontracts/contracts/multi_read_erc20.py @@ -1,5 +1,5 @@ # v0.1.0 -# { "Depends": "py-genlayer:test" } +# { "Depends": "py-genlayer:1jb45aa8ynh2a9c9xn3b7qqh8sm5q93hwfp7jqmwsfhh8jpz09h6" } from genlayer import * diff --git a/tests/integration/icontracts/contracts/multi_tenant_storage.py b/tests/integration/icontracts/contracts/multi_tenant_storage.py index f51f4e442..2fe1192ed 100644 --- a/tests/integration/icontracts/contracts/multi_tenant_storage.py +++ b/tests/integration/icontracts/contracts/multi_tenant_storage.py @@ -1,5 +1,5 @@ # v0.1.0 -# { "Depends": "py-genlayer:test" } +# { "Depends": "py-genlayer:1jb45aa8ynh2a9c9xn3b7qqh8sm5q93hwfp7jqmwsfhh8jpz09h6" } from genlayer import * diff --git a/tests/integration/icontracts/contracts/read_erc20.py b/tests/integration/icontracts/contracts/read_erc20.py index 9703af1dd..85adfac0b 100644 --- a/tests/integration/icontracts/contracts/read_erc20.py +++ b/tests/integration/icontracts/contracts/read_erc20.py @@ -1,5 +1,5 @@ # v0.1.0 -# { "Depends": "py-genlayer:test" } +# { "Depends": "py-genlayer:1jb45aa8ynh2a9c9xn3b7qqh8sm5q93hwfp7jqmwsfhh8jpz09h6" } from genlayer import * diff --git a/tests/integration/icontracts/contracts/storage_versions/__init__.py b/tests/integration/icontracts/contracts/storage_versions/__init__.py new file mode 100644 index 000000000..4e7ff66a4 --- /dev/null +++ b/tests/integration/icontracts/contracts/storage_versions/__init__.py @@ -0,0 +1,2 @@ +# Storage contracts for different py-genlayer versions +# Used for retrocompatibility testing diff --git a/tests/integration/icontracts/contracts/storage_versions/storage_v0_1_0.py b/tests/integration/icontracts/contracts/storage_versions/storage_v0_1_0.py new file mode 100644 index 000000000..861a63b10 --- /dev/null +++ b/tests/integration/icontracts/contracts/storage_versions/storage_v0_1_0.py @@ -0,0 +1,19 @@ +# v0.1.0 +# { "Depends": "py-genlayer:15qfivjvy80800rh998pcxmd2m8va1wq2qzqhz850n8ggcr4i9q0" } + +from genlayer import * + + +class Storage(gl.Contract): + storage: str + + def __init__(self, initial_storage: str): + self.storage = initial_storage + + @gl.public.view + def get_storage(self) -> str: + return self.storage + + @gl.public.write + def update_storage(self, new_storage: str) -> None: + self.storage = new_storage diff --git a/tests/integration/icontracts/contracts/storage_versions/storage_v0_1_3.py b/tests/integration/icontracts/contracts/storage_versions/storage_v0_1_3.py new file mode 100644 index 000000000..cda6af632 --- /dev/null +++ b/tests/integration/icontracts/contracts/storage_versions/storage_v0_1_3.py @@ -0,0 +1,19 @@ +# v0.1.3 +# { "Depends": "py-genlayer:1j12s63yfjpva9ik2xgnffgrs6v44y1f52jvj9w7xvdn7qckd379" } + +from genlayer import * + + +class Storage(gl.Contract): + storage: str + + def __init__(self, initial_storage: str): + self.storage = initial_storage + + @gl.public.view + def get_storage(self) -> str: + return self.storage + + @gl.public.write + def update_storage(self, new_storage: str) -> None: + self.storage = new_storage diff --git a/tests/integration/icontracts/contracts/storage_versions/storage_v0_1_8.py b/tests/integration/icontracts/contracts/storage_versions/storage_v0_1_8.py new file mode 100644 index 000000000..98ba362e5 --- /dev/null +++ b/tests/integration/icontracts/contracts/storage_versions/storage_v0_1_8.py @@ -0,0 +1,19 @@ +# v0.1.8 +# { "Depends": "py-genlayer:132536jbnxkd1axfxg5rpfr5b60cr11adm2y4r90hgn0l59qsp9w" } + +from genlayer import * + + +class Storage(gl.Contract): + storage: str + + def __init__(self, initial_storage: str): + self.storage = initial_storage + + @gl.public.view + def get_storage(self) -> str: + return self.storage + + @gl.public.write + def update_storage(self, new_storage: str) -> None: + self.storage = new_storage diff --git a/tests/integration/icontracts/contracts/storage_versions/storage_v0_2_1.py b/tests/integration/icontracts/contracts/storage_versions/storage_v0_2_1.py new file mode 100644 index 000000000..1391a0000 --- /dev/null +++ b/tests/integration/icontracts/contracts/storage_versions/storage_v0_2_1.py @@ -0,0 +1,19 @@ +# v0.2.1 +# { "Depends": "py-genlayer:1jb45aa8ynh2a9c9xn3b7qqh8sm5q93hwfp7jqmwsfhh8jpz09h6" } + +from genlayer import * + + +class Storage(gl.Contract): + storage: str + + def __init__(self, initial_storage: str): + self.storage = initial_storage + + @gl.public.view + def get_storage(self) -> str: + return self.storage + + @gl.public.write + def update_storage(self, new_storage: str) -> None: + self.storage = new_storage diff --git a/tests/integration/icontracts/contracts/storage_versions/storage_v0_2_4.py b/tests/integration/icontracts/contracts/storage_versions/storage_v0_2_4.py new file mode 100644 index 000000000..0896f8d11 --- /dev/null +++ b/tests/integration/icontracts/contracts/storage_versions/storage_v0_2_4.py @@ -0,0 +1,19 @@ +# v0.2.4 +# { "Depends": "py-genlayer:1jb45aa8ynh2a9c9xn3b7qqh8sm5q93hwfp7jqmwsfhh8jpz09h6" } + +from genlayer import * + + +class Storage(gl.Contract): + storage: str + + def __init__(self, initial_storage: str): + self.storage = initial_storage + + @gl.public.view + def get_storage(self) -> str: + return self.storage + + @gl.public.write + def update_storage(self, new_storage: str) -> None: + self.storage = new_storage diff --git a/tests/integration/icontracts/contracts/storage_versions/storage_v0_2_5.py b/tests/integration/icontracts/contracts/storage_versions/storage_v0_2_5.py new file mode 100644 index 000000000..b61828168 --- /dev/null +++ b/tests/integration/icontracts/contracts/storage_versions/storage_v0_2_5.py @@ -0,0 +1,19 @@ +# v0.2.5 +# { "Depends": "py-genlayer:1jb45aa8ynh2a9c9xn3b7qqh8sm5q93hwfp7jqmwsfhh8jpz09h6" } + +from genlayer import * + + +class Storage(gl.Contract): + storage: str + + def __init__(self, initial_storage: str): + self.storage = initial_storage + + @gl.public.view + def get_storage(self) -> str: + return self.storage + + @gl.public.write + def update_storage(self, new_storage: str) -> None: + self.storage = new_storage diff --git a/tests/integration/test_upgrade_contract.py b/tests/integration/test_upgrade_contract.py index 7f808cd6d..48e6656bd 100644 --- a/tests/integration/test_upgrade_contract.py +++ b/tests/integration/test_upgrade_contract.py @@ -154,7 +154,7 @@ def write_contract_method( # ============================================================================= CONTRACT_V1 = """# v0.1.0 -# { "Depends": "py-genlayer:latest" } +# { "Depends": "py-genlayer:1jb45aa8ynh2a9c9xn3b7qqh8sm5q93hwfp7jqmwsfhh8jpz09h6" } from genlayer import * @@ -188,7 +188,7 @@ def set_name(self, new_name: str) -> None: """ CONTRACT_V2 = """# v0.1.0 -# { "Depends": "py-genlayer:latest" } +# { "Depends": "py-genlayer:1jb45aa8ynh2a9c9xn3b7qqh8sm5q93hwfp7jqmwsfhh8jpz09h6" } from genlayer import * @@ -226,7 +226,7 @@ def new_method(self) -> str: """ CONTRACT_V3_WITH_NEW_STATE = """# v0.1.0 -# { "Depends": "py-genlayer:latest" } +# { "Depends": "py-genlayer:1jb45aa8ynh2a9c9xn3b7qqh8sm5q93hwfp7jqmwsfhh8jpz09h6" } from genlayer import * @@ -266,7 +266,7 @@ def set_name(self, new_name: str) -> None: """ INVALID_CONTRACT = """# v0.1.0 -# { "Depends": "py-genlayer:latest" } +# { "Depends": "py-genlayer:1jb45aa8ynh2a9c9xn3b7qqh8sm5q93hwfp7jqmwsfhh8jpz09h6" } from genlayer import * @@ -276,7 +276,7 @@ def __init__(self): """ SIMPLE_CONTRACT = """# v0.1.0 -# { "Depends": "py-genlayer:latest" } +# { "Depends": "py-genlayer:1jb45aa8ynh2a9c9xn3b7qqh8sm5q93hwfp7jqmwsfhh8jpz09h6" } from genlayer import * diff --git a/tests/test_linter_endpoint.py b/tests/test_linter_endpoint.py index 20f88ad1b..a7c0cb053 100644 --- a/tests/test_linter_endpoint.py +++ b/tests/test_linter_endpoint.py @@ -21,8 +21,8 @@ def get_balance(self) -> u256: # Should return int return self.balance """ -# Valid contract -VALID_CONTRACT = """# { "Depends": "py-genlayer:test" } +# Contract with invalid version (latest/test not allowed) +CONTRACT_WITH_INVALID_VERSION = """# { "Depends": "py-genlayer:latest" } from genlayer import * @@ -30,7 +30,23 @@ class TestContract(gl.Contract): balance: u256 def __init__(self): - self.balance = 0 + self.balance = u256(0) + + @gl.public.view + def get_balance(self) -> int: + return self.balance +""" + +# Valid contract with fixed version hash +VALID_CONTRACT = """# { "Depends": "py-genlayer:1jb45aa8ynh2a9c9xn3b7qqh8sm5q93hwfp7jqmwsfhh8jpz09h6" } + +from genlayer import * + +class TestContract(gl.Contract): + balance: u256 + + def __init__(self): + self.balance = u256(0) @gl.public.view def get_balance(self) -> int: @@ -76,14 +92,17 @@ def test_linter_endpoint(url="http://localhost:4000/api"): if issue["suggestion"]: print(f" 💡 {issue['suggestion']}") - # Test 2: Valid contract - print("\n2. Testing valid contract:") + # Test 2: Contract with invalid runner version (latest/test) + print("\n2. Testing contract with invalid runner version:") response = requests.post( url, json={ "jsonrpc": "2.0", "method": "sim_lintContract", - "params": {"source_code": VALID_CONTRACT, "filename": "valid_contract.py"}, + "params": { + "source_code": CONTRACT_WITH_INVALID_VERSION, + "filename": "invalid_version.py", + }, "id": 2, }, timeout=10, @@ -93,6 +112,45 @@ def test_linter_endpoint(url="http://localhost:4000/api"): payload = response.json() assert "result" in payload, f"Unexpected payload: {payload}" + contract_result = payload["result"] + total_issues = contract_result["summary"]["total"] + assert total_issues > 0, "Expected the linter to report invalid version issue" + + # Check that we have an INVALID_RUNNER_VERSION error + invalid_version_issues = [ + i + for i in contract_result["results"] + if i["rule_id"] == "INVALID_RUNNER_VERSION" + ] + assert ( + len(invalid_version_issues) > 0 + ), "Expected INVALID_RUNNER_VERSION error for 'latest' version" + + print(f"✅ Found {total_issues} issues including invalid runner version:") + for issue in contract_result["results"]: + print( + f" - Line {issue['line']}: {issue['severity'].upper()} - {issue['message']}" + ) + if issue["suggestion"]: + print(f" 💡 {issue['suggestion']}") + + # Test 3: Valid contract + print("\n3. Testing valid contract:") + response = requests.post( + url, + json={ + "jsonrpc": "2.0", + "method": "sim_lintContract", + "params": {"source_code": VALID_CONTRACT, "filename": "valid_contract.py"}, + "id": 3, + }, + timeout=10, + ) + + response.raise_for_status() + payload = response.json() + assert "result" in payload, f"Unexpected payload: {payload}" + total_issues = payload["result"]["summary"]["total"] assert ( total_issues == 0 diff --git a/tests/unit/test_contract_linter.py b/tests/unit/test_contract_linter.py new file mode 100644 index 000000000..0973f6362 --- /dev/null +++ b/tests/unit/test_contract_linter.py @@ -0,0 +1,169 @@ +""" +Unit tests for ContractLinter custom rules. +""" + +import pytest +from unittest.mock import patch, MagicMock + + +class TestInvalidRunnerVersion: + """Tests for the INVALID_RUNNER_VERSION custom linting rule.""" + + def test_detects_latest_version(self): + """Should detect 'latest' as an invalid runner version.""" + from backend.protocol_rpc.contract_linter import ContractLinter + + linter = ContractLinter() + source_code = '# { "Depends": "py-genlayer:latest" }\nfrom genlayer import *' + + issues = linter._check_invalid_runner_version(source_code, "test.py") + + assert len(issues) == 1 + assert issues[0]["rule_id"] == "INVALID_RUNNER_VERSION" + assert issues[0]["severity"] == "error" + assert issues[0]["line"] == 1 + assert "latest" in issues[0]["message"] + assert issues[0]["suggestion"] is not None + + def test_detects_test_version(self): + """Should detect 'test' as an invalid runner version.""" + from backend.protocol_rpc.contract_linter import ContractLinter + + linter = ContractLinter() + source_code = '# { "Depends": "py-genlayer:test" }\nfrom genlayer import *' + + issues = linter._check_invalid_runner_version(source_code, "test.py") + + assert len(issues) == 1 + assert issues[0]["rule_id"] == "INVALID_RUNNER_VERSION" + assert issues[0]["severity"] == "error" + assert "test" in issues[0]["message"] + + def test_allows_valid_hash_version(self): + """Should allow valid hash versions.""" + from backend.protocol_rpc.contract_linter import ContractLinter + + linter = ContractLinter() + source_code = '# { "Depends": "py-genlayer:1jb45aa8ynh2a9c9xn3b7qqh8sm5q93hwfp7jqmwsfhh8jpz09h6" }\nfrom genlayer import *' + + issues = linter._check_invalid_runner_version(source_code, "test.py") + + assert len(issues) == 0 + + def test_no_issues_without_depends_header(self): + """Should not report issues if there's no Depends header.""" + from backend.protocol_rpc.contract_linter import ContractLinter + + linter = ContractLinter() + source_code = "# Just a comment\nfrom genlayer import *" + + issues = linter._check_invalid_runner_version(source_code, "test.py") + + assert len(issues) == 0 + + def test_handles_whitespace_variations(self): + """Should detect invalid versions with different whitespace.""" + from backend.protocol_rpc.contract_linter import ContractLinter + + linter = ContractLinter() + + # Extra spaces + source_code = '# { "Depends" : "py-genlayer:latest" }' + issues = linter._check_invalid_runner_version(source_code, "test.py") + assert len(issues) == 1 + + def test_correct_line_number_for_multiline(self): + """Should report correct line number when header is not on first line.""" + from backend.protocol_rpc.contract_linter import ContractLinter + + linter = ContractLinter() + source_code = ( + '# v0.1.0\n# { "Depends": "py-genlayer:test" }\nfrom genlayer import *' + ) + + issues = linter._check_invalid_runner_version(source_code, "test.py") + + assert len(issues) == 1 + assert issues[0]["line"] == 2 + + @patch("genvm_linter.linter.GenVMLinter", autospec=True) + def test_lint_contract_includes_custom_rules(self, mock_genvm_linter_class): + """Should include custom rule issues in lint_contract output.""" + from backend.protocol_rpc.contract_linter import ContractLinter + + # Mock the external genvm_linter to return no issues + mock_linter = MagicMock() + mock_linter.lint_source.return_value = [] + mock_genvm_linter_class.return_value = mock_linter + + linter = ContractLinter() + source_code = '# { "Depends": "py-genlayer:latest" }\nfrom genlayer import *' + + result = linter.lint_contract(source_code, "test.py") + + assert result["summary"]["total"] == 1 + assert result["summary"]["by_severity"]["error"] == 1 + assert len(result["results"]) == 1 + assert result["results"][0]["rule_id"] == "INVALID_RUNNER_VERSION" + + +class TestSharedVersionValidation: + """Tests for the shared check_invalid_runner_version function. + + This function is used by both the linter and the contract upgrade endpoint, + so testing it ensures both features are covered. + """ + + def test_detects_latest_version(self): + """Should detect 'latest' as an invalid runner version.""" + from backend.protocol_rpc.contract_linter import check_invalid_runner_version + + new_code = '# { "Depends": "py-genlayer:latest" }\nfrom genlayer import *' + + has_invalid, version = check_invalid_runner_version(new_code) + + assert has_invalid is True + assert version == "latest" + + def test_detects_test_version(self): + """Should detect 'test' as an invalid runner version.""" + from backend.protocol_rpc.contract_linter import check_invalid_runner_version + + new_code = '# { "Depends": "py-genlayer:test" }\nfrom genlayer import *' + + has_invalid, version = check_invalid_runner_version(new_code) + + assert has_invalid is True + assert version == "test" + + def test_allows_valid_hash_version(self): + """Should allow valid hash versions.""" + from backend.protocol_rpc.contract_linter import check_invalid_runner_version + + new_code = '# { "Depends": "py-genlayer:1jb45aa8ynh2a9c9xn3b7qqh8sm5q93hwfp7jqmwsfhh8jpz09h6" }\nfrom genlayer import *' + + has_invalid, version = check_invalid_runner_version(new_code) + + assert has_invalid is False + assert version is None + + def test_allows_no_depends_header(self): + """Should allow code without a Depends header.""" + from backend.protocol_rpc.contract_linter import check_invalid_runner_version + + new_code = "# Just a comment\nfrom genlayer import *" + + has_invalid, version = check_invalid_runner_version(new_code) + + assert has_invalid is False + assert version is None + + def test_error_message_format(self): + """Error message should contain the version and example hash.""" + from backend.protocol_rpc.contract_linter import INVALID_VERSION_ERROR_MESSAGE + + message = INVALID_VERSION_ERROR_MESSAGE.format(version="latest") + + assert "latest" in message + assert "not allowed" in message + assert "1jb45aa8ynh2a9c9xn3b7qqh8sm5q93hwfp7jqmwsfhh8jpz09h6" in message